add swiftUI code
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/// A protocol indicating that an activity or action supports cancellation.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Supporting Types
|
||||
///
|
||||
/// - ``AnyDatabaseCancellable``
|
||||
public protocol DatabaseCancellable {
|
||||
/// Cancel the activity.
|
||||
func cancel()
|
||||
}
|
||||
|
||||
/// A type-erasing cancellable object that executes a provided closure
|
||||
/// when canceled.
|
||||
///
|
||||
/// An `AnyDatabaseCancellable` instance automatically calls ``cancel()``
|
||||
/// when deinitialized.
|
||||
public class AnyDatabaseCancellable: DatabaseCancellable {
|
||||
private var _cancel: (() -> Void)?
|
||||
|
||||
/// Initializes the cancellable object with the given cancel-time closure.
|
||||
public init(cancel: @escaping () -> Void) {
|
||||
_cancel = cancel
|
||||
}
|
||||
|
||||
/// Creates a cancellable object that forwards cancellation to `base`.
|
||||
public convenience init(_ base: some DatabaseCancellable) {
|
||||
var cancellable = Optional.some(base)
|
||||
self.init {
|
||||
cancellable?.cancel()
|
||||
cancellable = nil // Release memory
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
_cancel?()
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
// Don't prevent multiple concurrent calls to _cancel, because it is
|
||||
// pointless. But release memory!
|
||||
_cancel?()
|
||||
_cancel = nil
|
||||
}
|
||||
}
|
||||
+905
@@ -0,0 +1,905 @@
|
||||
import Foundation
|
||||
|
||||
/// `ValueConcurrentObserver` observes the database for `ValueObservation`, in
|
||||
/// a `DatabasePool`.
|
||||
///
|
||||
/// It performs the following database observation cycle:
|
||||
///
|
||||
/// 1. Start observation or detect a database change
|
||||
/// 2. Fetch
|
||||
/// 3. Reduce
|
||||
/// 4. Notify
|
||||
///
|
||||
/// **Fetch** is performed concurrently (hence the name of this observer).
|
||||
///
|
||||
/// **Reduce** is the operation that turns the fetched database values into the
|
||||
/// observed values. Those are not the same. Consider, for example, the `map()`
|
||||
/// and `removeDuplicates()` operators: they perform their job during the
|
||||
/// reducing stage.
|
||||
///
|
||||
/// **Notify** is calling user callbacks, in case of database change or error.
|
||||
final class ValueConcurrentObserver<Reducer: ValueReducer, Scheduler: ValueObservationScheduler> {
|
||||
// MARK: - Configuration
|
||||
//
|
||||
// Configuration is not mutable.
|
||||
|
||||
/// How to schedule observed values and errors.
|
||||
private let scheduler: Scheduler
|
||||
|
||||
/// Configures the tracked database region.
|
||||
private let trackingMode: ValueObservationTrackingMode
|
||||
|
||||
// MARK: - Mutable State
|
||||
//
|
||||
// The observer has four distinct mutable states that evolve independently,
|
||||
// and are made thread-safe with various mechanisms:
|
||||
//
|
||||
// - A `DatabaseAccess`: ability to access the database. It is constant but
|
||||
// turns nil after the observation fails or is cancelled, in order to
|
||||
// release memory and resources when the observation completes. It is
|
||||
// guarded by `lock`, because observation can fail or be cancelled from
|
||||
// multiple threads.
|
||||
//
|
||||
// - A `NotificationCallbacks`: ability to notify observation events. It is
|
||||
// constant but turns nil when failure or cancellation is notified, in
|
||||
// order to release memory and resources when the observation completes.
|
||||
// It is guarded by `lock`, because observation can fail or be cancelled
|
||||
// from multiple threads.
|
||||
//
|
||||
// - An `ObservationState`: relationship with the `TransactionObserver`
|
||||
// protocol. It is only accessed from the serialized writer
|
||||
// dispatch queue.
|
||||
//
|
||||
// - A `Reducer`: the observation reducer, only accessed from the
|
||||
// serialized dispatch queue `reduceQueue`.
|
||||
//
|
||||
// The `reduceQueue` guarantees that fresh value notifications have the same
|
||||
// order as transactions. It is different from the serialized writer
|
||||
// dispatch queue because we do not want to lock the database as
|
||||
// computations (`map`, `removeDuplicates()`, etc.) are performed.
|
||||
//
|
||||
// Despite being protected by the same lock, `DatabaseAccess` and
|
||||
// `NotificationCallbacks` are not merged together. This is because the
|
||||
// observer does not lose `DatabaseAccess` at the same time it
|
||||
// looses `NotificationCallbacks`:
|
||||
//
|
||||
// - In case of cancellation, `NotificationCallbacks` is lost first, and
|
||||
// `DatabaseAccess` is lost asynchronously, after the observer could
|
||||
// resign as a transaction observer. See `cancel()`.
|
||||
//
|
||||
// - In case of error, `DatabaseAccess` may be lost synchronously, and
|
||||
// `NotificationCallbacks` is lost asynchronously, after the error could
|
||||
// be notified. See error catching clauses.
|
||||
|
||||
/// Ability to access the database
|
||||
private struct DatabaseAccess {
|
||||
/// The observed DatabasePool.
|
||||
let dbPool: DatabasePool
|
||||
|
||||
/// A reducer that fetches database values.
|
||||
private let reducer: Reducer
|
||||
|
||||
init(dbPool: DatabasePool, reducer: Reducer) {
|
||||
self.dbPool = dbPool
|
||||
self.reducer = reducer
|
||||
}
|
||||
|
||||
func fetch(_ db: Database) throws -> Reducer.Fetched {
|
||||
try db.isolated(readOnly: true) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRecordingObservedRegion(_ db: Database) throws -> (Reducer.Fetched, DatabaseRegion) {
|
||||
var region = DatabaseRegion()
|
||||
let fetchedValue = try db.isolated(readOnly: true) {
|
||||
try db.recordingSelection(®ion) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
return try (fetchedValue, region.observableRegion(db))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fetching state for observation of constant regions.
|
||||
enum FetchingState {
|
||||
/// No need to fetch.
|
||||
case idle
|
||||
|
||||
/// Waiting for a fetched value.
|
||||
case fetching
|
||||
|
||||
/// Waiting for a fetched value, and for a subsequent fetch after
|
||||
/// that, because a change has been detected as we were fetching.
|
||||
case fetchingAndNeedsFetch
|
||||
}
|
||||
|
||||
/// Ability to notify observation events
|
||||
private struct NotificationCallbacks {
|
||||
let events: ValueObservationEvents
|
||||
let onChange: (Reducer.Value) -> Void
|
||||
}
|
||||
|
||||
/// Relationship with the `TransactionObserver` protocol
|
||||
private struct ObservationState {
|
||||
var region: DatabaseRegion?
|
||||
var isModified = false
|
||||
|
||||
static var notObserving: Self { .init(region: nil, isModified: false) }
|
||||
}
|
||||
|
||||
/// Protects `databaseAccess` and `notificationCallbacks`.
|
||||
///
|
||||
/// Check out this compiler bug:
|
||||
/// - <https://github.com/groue/GRDB.swift/issues/1026>
|
||||
/// - <https://github.com/groue/GRDB.swift/pull/1025>
|
||||
private let lock = NSLock()
|
||||
|
||||
/// The dispatch queue where database values are reduced into observed
|
||||
/// values before being notified. Protects `reducer`.
|
||||
private let reduceQueue: DispatchQueue
|
||||
|
||||
/// Access to the database, protected by `lock`.
|
||||
private var databaseAccess: DatabaseAccess?
|
||||
|
||||
/// Ability to notify observation events, protected by `lock`.
|
||||
private var notificationCallbacks: NotificationCallbacks?
|
||||
|
||||
/// The fetching state for observation of constant regions.
|
||||
@LockedBox private var fetchingState = FetchingState.idle
|
||||
|
||||
/// Support for `TransactionObserver`, protected by the serialized writer
|
||||
/// dispatch queue.
|
||||
private var observationState = ObservationState.notObserving
|
||||
|
||||
/// Protected by `reduceQueue`.
|
||||
private var reducer: Reducer
|
||||
|
||||
init(
|
||||
dbPool: DatabasePool,
|
||||
scheduler: Scheduler,
|
||||
trackingMode: ValueObservationTrackingMode,
|
||||
reducer: Reducer,
|
||||
events: ValueObservationEvents,
|
||||
onChange: @escaping (Reducer.Value) -> Void)
|
||||
{
|
||||
// Configuration
|
||||
self.scheduler = scheduler
|
||||
self.trackingMode = trackingMode
|
||||
|
||||
// State
|
||||
self.databaseAccess = DatabaseAccess(
|
||||
dbPool: dbPool,
|
||||
// ValueReducer semantics guarantees that reducer._fetch
|
||||
// is independent from the reducer state
|
||||
reducer: reducer)
|
||||
self.notificationCallbacks = NotificationCallbacks(events: events, onChange: onChange)
|
||||
self.reducer = reducer
|
||||
self.reduceQueue = DispatchQueue(
|
||||
label: dbPool.configuration.identifier(
|
||||
defaultLabel: "GRDB",
|
||||
purpose: "ValueObservation"),
|
||||
qos: dbPool.configuration.readQoS)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Starting the Observation
|
||||
//
|
||||
// When we start an observation from a `DatabasePool`, we do not wait for an
|
||||
// access to the writer connection before fetching the initial value. That is
|
||||
// because the user of a `DatabasePool` expects to be notified with the initial
|
||||
// value as fast as possible, even if a long write transaction is running in the
|
||||
// background.
|
||||
//
|
||||
// We will thus perform the initial fetch from a reader connection, and only
|
||||
// then access the writer connection, and start database observation.
|
||||
//
|
||||
// Between this initial fetch, and the beginning of database observation, any
|
||||
// number of unobserved writes may occur. We must notify the changes that happen
|
||||
// during this unobserved window. But how do we spot them, since we were not
|
||||
// observing the database yet?
|
||||
//
|
||||
// The solution depends on the presence of the `SQLITE_ENABLE_SNAPSHOT`
|
||||
// SQLite compilation flag.
|
||||
//
|
||||
// Without `SQLITE_ENABLE_SNAPSHOT`, we have NO WAY to detect if the database
|
||||
// was changed or not between the initial fetch and the beginning of database
|
||||
// observation. We will thus always perform a secondary fetch from the initial
|
||||
// access to the writer connection. Even if no change was performed. We may end
|
||||
// up notifying the same value twice. Such stuttering is a documented glitch,
|
||||
// and the user can perform deduplication with the
|
||||
// `removeDuplicates()` operator.
|
||||
//
|
||||
// With `SQLITE_ENABLE_SNAPSHOT`, we can detect if the database was not changed
|
||||
// at all between the initial fetch and the beginning of database observation.
|
||||
// If the database was changed, we perform a secondary fetch from the initial
|
||||
// access to the writer connection. It is possible that the change was not
|
||||
// related to the observed value. Actually we have NO WAY to know. So we may end
|
||||
// up notifying the same value twice. Such stuttering is a documented glitch,
|
||||
// and the user can perform deduplication with the
|
||||
// `removeDuplicates()` operator.
|
||||
//
|
||||
// This is how we can both:
|
||||
// 1. Start the observation without waiting for a write access (the expected
|
||||
// benefit of `DatabasePool`).
|
||||
// 2. Make sure we do not miss a change (a documented guarantee)
|
||||
//
|
||||
// Support for `SQLITE_ENABLE_SNAPSHOT` is implemented by our
|
||||
// `WALSnapshot` class.
|
||||
extension ValueConcurrentObserver {
|
||||
// Starts the observation
|
||||
func start() -> AnyDatabaseCancellable {
|
||||
let (notificationCallbacks, databaseAccess) = lock.synchronized {
|
||||
(self.notificationCallbacks, self.databaseAccess)
|
||||
}
|
||||
guard let notificationCallbacks, let databaseAccess else {
|
||||
// Likely a GRDB bug: during a synchronous start, user is not
|
||||
// able to cancel observation.
|
||||
fatalError("can't start a cancelled or failed observation")
|
||||
}
|
||||
|
||||
if scheduler.immediateInitialValue() {
|
||||
do {
|
||||
// Start the observation in an synchronous way
|
||||
let initialValue = try syncStart(from: databaseAccess)
|
||||
|
||||
// Notify the initial value from the dispatch queue the
|
||||
// observation was started from
|
||||
notificationCallbacks.onChange(initialValue)
|
||||
} catch {
|
||||
// Notify error from the dispatch queue the observation
|
||||
// was started from.
|
||||
notificationCallbacks.events.didFail?(error)
|
||||
|
||||
// Early return!
|
||||
return AnyDatabaseCancellable { /* nothing to cancel */ }
|
||||
}
|
||||
} else {
|
||||
// Start the observation in an asynchronous way
|
||||
asyncStart(from: databaseAccess)
|
||||
}
|
||||
|
||||
// Make sure the returned cancellable cancels the observation
|
||||
// when deallocated. We can't relying on the deallocation of
|
||||
// self to trigger early cancellation, because self may be retained by
|
||||
// some closure waiting to run in some DispatchQueue.
|
||||
return AnyDatabaseCancellable(self)
|
||||
}
|
||||
|
||||
private func startObservation(_ writerDB: Database, observedRegion: DatabaseRegion) {
|
||||
observationState.region = observedRegion
|
||||
assert(observationState.isModified == false)
|
||||
writerDB.add(transactionObserver: self, extent: .observerLifetime)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
extension ValueConcurrentObserver {
|
||||
/// Synchronously starts the observation, and returns the initial value.
|
||||
///
|
||||
/// Unlike `asyncStart()`, this method does not notify the initial value or error.
|
||||
private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value {
|
||||
// Start from a read access. The whole point of using a DatabasePool
|
||||
// for observing the database is to be able to fetch the initial value
|
||||
// without having to wait for an eventual long-running write
|
||||
// transaction to complete.
|
||||
//
|
||||
// We perform the initial read from a long-lived WAL snapshot
|
||||
// transaction, because it is a handy way to keep a read transaction
|
||||
// open until we grab a write access, and compare the database versions.
|
||||
let initialFetchTransaction: WALSnapshotTransaction
|
||||
do {
|
||||
initialFetchTransaction = try databaseAccess.dbPool.walSnapshotTransaction()
|
||||
} catch DatabaseError.SQLITE_ERROR {
|
||||
// We can't create a WAL snapshot. The WAL file is probably
|
||||
// missing, or is truncated. Let's degrade the observation
|
||||
// by not using any snapshot.
|
||||
// For more information, see <https://github.com/groue/GRDB.swift/issues/1383>
|
||||
return try syncStartWithoutWALSnapshot(from: databaseAccess)
|
||||
}
|
||||
|
||||
let (fetchedValue, initialRegion): (Reducer.Fetched, DatabaseRegion) = try initialFetchTransaction.read { db in
|
||||
switch trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
let fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
let initialRegion = try region.observableRegion(db)
|
||||
return (fetchedValue, initialRegion)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
let (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
return (fetchedValue, initialRegion)
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce
|
||||
let initialValue = try reduceQueue.sync {
|
||||
guard let initialValue = try reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
return initialValue
|
||||
}
|
||||
|
||||
// Start observation
|
||||
asyncStartObservation(
|
||||
from: databaseAccess,
|
||||
initialFetchTransaction: initialFetchTransaction,
|
||||
initialRegion: initialRegion)
|
||||
|
||||
return initialValue
|
||||
}
|
||||
|
||||
/// Asynchronously starts the observation
|
||||
///
|
||||
/// Unlike `syncStart()`, this method does notify the initial value or error.
|
||||
private func asyncStart(from databaseAccess: DatabaseAccess) {
|
||||
// Start from a read access. The whole point of using a DatabasePool
|
||||
// for observing the database is to be able to fetch the initial value
|
||||
// without having to wait for an eventual long-running write
|
||||
// transaction to complete.
|
||||
//
|
||||
// We perform the initial read from a long-lived WAL snapshot
|
||||
// transaction, because it is a handy way to keep a read transaction
|
||||
// open until we grab a write access, and compare the database versions.
|
||||
databaseAccess.dbPool.asyncWALSnapshotTransaction { result in
|
||||
let (isNotifying, databaseAccess) = self.lock.synchronized {
|
||||
(self.notificationCallbacks != nil, self.databaseAccess)
|
||||
}
|
||||
guard isNotifying, let databaseAccess else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
let initialFetchTransaction = try result.get()
|
||||
// Second async jump because that's how
|
||||
// `DatabasePool.asyncWALSnapshotTransaction` has to be used.
|
||||
initialFetchTransaction.asyncRead { db in
|
||||
do {
|
||||
let fetchedValue: Reducer.Fetched
|
||||
let initialRegion: DatabaseRegion
|
||||
|
||||
switch self.trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
initialRegion = try region.observableRegion(db)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
(fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// a database dispatch queue longer than necessary.
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
guard let initialValue = try self.reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
// Notify
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(initialValue)
|
||||
}
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start observation
|
||||
self.asyncStartObservation(
|
||||
from: databaseAccess,
|
||||
initialFetchTransaction: initialFetchTransaction,
|
||||
initialRegion: initialRegion)
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} catch DatabaseError.SQLITE_ERROR {
|
||||
// We can't create a WAL snapshot. The WAL file is probably
|
||||
// missing, or is truncated. Let's degrade the observation
|
||||
// by not using any snapshot.
|
||||
// For more information, see <https://github.com/groue/GRDB.swift/issues/1383>
|
||||
self.asyncStartWithoutWALSnapshot(from: databaseAccess)
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncStartObservation(
|
||||
from databaseAccess: DatabaseAccess,
|
||||
initialFetchTransaction: WALSnapshotTransaction,
|
||||
initialRegion: DatabaseRegion)
|
||||
{
|
||||
// We'll start the observation when we can access the writer
|
||||
// connection. Until then, maybe the database has been modified
|
||||
// since the initial fetch: we'll then need to notify a fresh value.
|
||||
//
|
||||
// To know if the database has been modified between the initial
|
||||
// fetch and the writer access, we'll compare WAL snapshots.
|
||||
//
|
||||
// WAL snapshots can only be compared if the database is not
|
||||
// checkpointed. That's why we'll keep `initialFetchTransaction`
|
||||
// alive until the comparison is done.
|
||||
//
|
||||
// However, we want to release `initialFetchTransaction` as soon as
|
||||
// possible, so that the reader connection it holds becomes
|
||||
// available for other reads. It will be released when this optional
|
||||
// is set to nil:
|
||||
var initialFetchTransaction: WALSnapshotTransaction? = initialFetchTransaction
|
||||
|
||||
databaseAccess.dbPool.asyncWriteWithoutTransaction { writerDB in
|
||||
let events = self.lock.synchronized { self.notificationCallbacks?.events }
|
||||
guard let events else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
var observedRegion = initialRegion
|
||||
|
||||
try writerDB.isolated(readOnly: true) {
|
||||
// Was the database modified since the initial fetch?
|
||||
let isModified: Bool
|
||||
if let currentWALSnapshot = try? WALSnapshot(writerDB) {
|
||||
let ordering = initialFetchTransaction!.walSnapshot.compare(currentWALSnapshot)
|
||||
assert(ordering <= 0, "Unexpected snapshot ordering")
|
||||
isModified = ordering < 0
|
||||
} else {
|
||||
// Can't compare: assume the database was modified.
|
||||
isModified = true
|
||||
}
|
||||
|
||||
// Comparison done: end the WAL snapshot transaction
|
||||
// and release its reader connection.
|
||||
initialFetchTransaction = nil
|
||||
|
||||
if isModified {
|
||||
events.databaseDidChange?()
|
||||
|
||||
// Fetch
|
||||
let fetchedValue: Reducer.Fetched
|
||||
|
||||
switch self.trackingMode {
|
||||
case .constantRegion:
|
||||
fetchedValue = try databaseAccess.fetch(writerDB)
|
||||
events.willTrackRegion?(initialRegion)
|
||||
self.startObservation(writerDB, observedRegion: initialRegion)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
(fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
self.startObservation(writerDB, observedRegion: observedRegion)
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering
|
||||
// between transactions and notifications!
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
let value = try self.reducer._value(fetchedValue)
|
||||
|
||||
// Notify
|
||||
if let value {
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let dbPool = self.lock.synchronized { self.databaseAccess?.dbPool }
|
||||
dbPool?.asyncWriteWithoutTransaction { writerDB in
|
||||
self.stopDatabaseObservation(writerDB)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
events.willTrackRegion?(initialRegion)
|
||||
self.startObservation(writerDB, observedRegion: initialRegion)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
extension ValueConcurrentObserver {
|
||||
private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value {
|
||||
try syncStartWithoutWALSnapshot(from: databaseAccess)
|
||||
}
|
||||
|
||||
private func asyncStart(from databaseAccess: DatabaseAccess) {
|
||||
asyncStartWithoutWALSnapshot(from: databaseAccess)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension ValueConcurrentObserver {
|
||||
/// Synchronously starts the observation, and returns the initial value.
|
||||
///
|
||||
/// Unlike `asyncStartWithoutWALSnapshot()`, this method does not notify the initial value or error.
|
||||
private func syncStartWithoutWALSnapshot(from databaseAccess: DatabaseAccess) throws -> Reducer.Value {
|
||||
// Start from a read access. The whole point of using a DatabasePool
|
||||
// for observing the database is to be able to fetch the initial value
|
||||
// without having to wait for an eventual long-running write
|
||||
// transaction to complete.
|
||||
let (fetchedValue, initialRegion) = try databaseAccess.dbPool.read { db -> (Reducer.Fetched, DatabaseRegion) in
|
||||
switch trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
let fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
let initialRegion = try region.observableRegion(db)
|
||||
return (fetchedValue, initialRegion)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
let (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
return (fetchedValue, initialRegion)
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce
|
||||
let initialValue = try reduceQueue.sync {
|
||||
guard let initialValue = try reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
return initialValue
|
||||
}
|
||||
|
||||
// Start observation
|
||||
asyncStartObservationWithoutWALSnapshot(
|
||||
from: databaseAccess,
|
||||
initialRegion: initialRegion)
|
||||
|
||||
return initialValue
|
||||
}
|
||||
|
||||
/// Asynchronously starts the observation
|
||||
///
|
||||
/// Unlike `syncStartWithoutWALSnapshot()`, this method does notify the initial value or error.
|
||||
private func asyncStartWithoutWALSnapshot(from databaseAccess: DatabaseAccess) {
|
||||
// Start from a read access. The whole point of using a DatabasePool
|
||||
// for observing the database is to be able to fetch the initial value
|
||||
// without having to wait for an eventual long-running write
|
||||
// transaction to complete.
|
||||
databaseAccess.dbPool.asyncRead { dbResult in
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
// Fetch
|
||||
let fetchedValue: Reducer.Fetched
|
||||
let initialRegion: DatabaseRegion
|
||||
let db = try dbResult.get()
|
||||
switch self.trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
initialRegion = try region.observableRegion(db)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
(fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// a database dispatch queue longer than necessary.
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
guard let initialValue = try self.reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
// Notify
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(initialValue)
|
||||
}
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start observation
|
||||
self.asyncStartObservationWithoutWALSnapshot(
|
||||
from: databaseAccess,
|
||||
initialRegion: initialRegion)
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncStartObservationWithoutWALSnapshot(
|
||||
from databaseAccess: DatabaseAccess,
|
||||
initialRegion: DatabaseRegion)
|
||||
{
|
||||
databaseAccess.dbPool.asyncWriteWithoutTransaction { writerDB in
|
||||
let events = self.lock.synchronized { self.notificationCallbacks?.events }
|
||||
guard let events else { return /* Cancelled */ }
|
||||
events.databaseDidChange?()
|
||||
|
||||
do {
|
||||
try writerDB.isolated(readOnly: true) {
|
||||
// Fetch
|
||||
let fetchedValue: Reducer.Fetched
|
||||
let observedRegion: DatabaseRegion
|
||||
switch self.trackingMode {
|
||||
case .constantRegion:
|
||||
fetchedValue = try databaseAccess.fetch(writerDB)
|
||||
observedRegion = initialRegion
|
||||
events.willTrackRegion?(initialRegion)
|
||||
self.startObservation(writerDB, observedRegion: initialRegion)
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
(fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
self.startObservation(writerDB, observedRegion: observedRegion)
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering
|
||||
// between transactions and notifications!
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
let value = try self.reducer._value(fetchedValue)
|
||||
|
||||
// Notify
|
||||
if let value {
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let dbPool = self.lock.synchronized { self.databaseAccess?.dbPool }
|
||||
dbPool?.asyncWriteWithoutTransaction { writerDB in
|
||||
self.stopDatabaseObservation(writerDB)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observing Database Transactions
|
||||
|
||||
extension ValueConcurrentObserver: TransactionObserver {
|
||||
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
|
||||
if let region = observationState.region {
|
||||
return region.isModified(byEventsOfKind: eventKind)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidChange() {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
|
||||
func databaseDidChange(with event: DatabaseEvent) {
|
||||
if let region = observationState.region, region.isModified(by: event) {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidCommit(_ writerDB: Database) {
|
||||
// Ignore transaction unless database was modified
|
||||
guard observationState.isModified else { return }
|
||||
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
|
||||
// Ignore transaction unless we are still notifying database events, and
|
||||
// we can still access the database.
|
||||
let (events, databaseAccess) = lock.synchronized {
|
||||
(notificationCallbacks?.events, self.databaseAccess)
|
||||
}
|
||||
guard let events, let databaseAccess else { return /* Cancelled */ }
|
||||
|
||||
events.databaseDidChange?()
|
||||
|
||||
// Fetch
|
||||
switch trackingMode {
|
||||
case .constantRegion, .constantRegionRecordedFromSelection:
|
||||
setNeedsFetching(databaseAccess: databaseAccess)
|
||||
|
||||
case .nonConstantRegionRecordedFromSelection:
|
||||
// When the tracked region is not constant, we can't perform
|
||||
// concurrent fetches of observed values.
|
||||
//
|
||||
// This is because after a concurrent fetch has acquired snapshot
|
||||
// isolation, and before it completes, a change can be performed
|
||||
// in the *next* tracked region. When this happens, the
|
||||
// concurrent fetch has loaded an obsolete value, and we need to
|
||||
// perform a new fetch, with the latest values. But the
|
||||
// observation was not triggered by the change because we didn't
|
||||
// know that this change was about to be tracked! This means
|
||||
// that we'd miss a change, and fail notifying the latest value.
|
||||
//
|
||||
// Conclusion: fetch from the writer connection, and update the
|
||||
// tracked region.
|
||||
do {
|
||||
let (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB)
|
||||
|
||||
// Don't spam the user with region tracking events: wait for an actual change
|
||||
if let willTrackRegion = events.willTrackRegion, observedRegion != observationState.region {
|
||||
willTrackRegion(observedRegion)
|
||||
}
|
||||
|
||||
observationState.region = observedRegion
|
||||
reduce(.success(fetchedValue))
|
||||
} catch {
|
||||
stopDatabaseObservation(writerDB)
|
||||
notifyError(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setNeedsFetching(databaseAccess: DatabaseAccess) {
|
||||
$fetchingState.update { state in
|
||||
switch state {
|
||||
case .idle:
|
||||
state = .fetching
|
||||
asyncFetch(databaseAccess: databaseAccess)
|
||||
|
||||
case .fetching:
|
||||
state = .fetchingAndNeedsFetch
|
||||
|
||||
case .fetchingAndNeedsFetch:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func asyncFetch(databaseAccess: DatabaseAccess) {
|
||||
databaseAccess.dbPool.asyncRead { [self] dbResult in
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
let fetchResult = dbResult.flatMap { db in
|
||||
Result { try databaseAccess.fetch(db) }
|
||||
}
|
||||
|
||||
self.reduce(fetchResult)
|
||||
|
||||
$fetchingState.update { state in
|
||||
switch state {
|
||||
case .idle:
|
||||
// GRDB bug
|
||||
preconditionFailure()
|
||||
|
||||
case .fetching:
|
||||
state = .idle
|
||||
|
||||
case .fetchingAndNeedsFetch:
|
||||
state = .fetching
|
||||
asyncFetch(databaseAccess: databaseAccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reduce(_ fetchResult: Result<Reducer.Fetched, Error>) {
|
||||
reduceQueue.async {
|
||||
do {
|
||||
let fetchedValue = try fetchResult.get()
|
||||
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
let value = try self.reducer._value(fetchedValue)
|
||||
|
||||
// Notify value
|
||||
if let value {
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let dbPool = self.lock.synchronized { self.databaseAccess?.dbPool }
|
||||
dbPool?.asyncWriteWithoutTransaction { writerDB in
|
||||
self.stopDatabaseObservation(writerDB)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidRollback(_ db: Database) {
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ending the Observation
|
||||
|
||||
extension ValueConcurrentObserver: DatabaseCancellable {
|
||||
func cancel() {
|
||||
// Notify cancellation
|
||||
let (events, dbPool): (ValueObservationEvents?, DatabasePool?) = lock.synchronized {
|
||||
let events = notificationCallbacks?.events
|
||||
// Set callbacks to nil so that we can't notify anything after
|
||||
// the cancellation.
|
||||
notificationCallbacks = nil
|
||||
return (events, databaseAccess?.dbPool)
|
||||
}
|
||||
|
||||
guard let events else { return /* Cancelled or failed */ }
|
||||
events.didCancel?()
|
||||
|
||||
// Stop observing the database
|
||||
// Do it asynchronously, so that we do not block the current thread:
|
||||
// cancellation may be triggered while a long write access is executing.
|
||||
guard let dbPool else { return /* Failed */ }
|
||||
dbPool.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyError(_ error: Error) {
|
||||
scheduler.schedule {
|
||||
let events = self.lock.synchronized {
|
||||
let events = self.notificationCallbacks?.events
|
||||
self.notificationCallbacks = nil
|
||||
return events
|
||||
}
|
||||
guard let events else { return /* Cancelled */ }
|
||||
events.didFail?(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDatabaseObservation(_ writerDB: Database) {
|
||||
writerDB.remove(transactionObserver: self)
|
||||
observationState = .notObserving
|
||||
lock.synchronized {
|
||||
databaseAccess = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
import Foundation
|
||||
|
||||
/// `ValueWriteOnlyObserver` observes the database for `ValueObservation`.
|
||||
///
|
||||
/// It performs the following database observation cycle:
|
||||
///
|
||||
/// 1. Start observation or detect a database change
|
||||
/// 2. Fetch
|
||||
/// 3. Reduce
|
||||
/// 4. Notify
|
||||
///
|
||||
/// **Fetch** is always performed from the writer database connection (hence
|
||||
/// the name of this observer).
|
||||
///
|
||||
/// **Reduce** is the operation that turns the fetched database values into the
|
||||
/// observed values. Those are not the same. Consider, for example, the `map()`
|
||||
/// and `removeDuplicates()` operators: they perform their job during the
|
||||
/// reducing stage.
|
||||
///
|
||||
/// **Notify** is calling user callbacks, in case of database change or error.
|
||||
final class ValueWriteOnlyObserver<
|
||||
Writer: DatabaseWriter,
|
||||
Reducer: ValueReducer,
|
||||
Scheduler: ValueObservationScheduler>
|
||||
{
|
||||
// MARK: - Configuration
|
||||
//
|
||||
// Configuration is not mutable.
|
||||
|
||||
/// How to schedule observed values and errors.
|
||||
private let scheduler: Scheduler
|
||||
|
||||
/// Configures the tracked database region.
|
||||
private let trackingMode: ValueObservationTrackingMode
|
||||
|
||||
// MARK: - Mutable State
|
||||
//
|
||||
// The observer has four distinct mutable states that evolve independently,
|
||||
// and are made thread-safe with various mechanisms:
|
||||
//
|
||||
// - A `DatabaseAccess`: ability to access the database. It is constant but
|
||||
// turns nil after the observation fails or is cancelled, in order to
|
||||
// release memory and resources when the observation completes. It is
|
||||
// guarded by `lock`, because observation can fail or be cancelled from
|
||||
// multiple threads.
|
||||
//
|
||||
// - A `NotificationCallbacks`: ability to notify observation events. It is
|
||||
// constant but turns nil when failure or cancellation is notified, in
|
||||
// order to release memory and resources when the observation completes.
|
||||
// It is guarded by `lock`, because observation can fail or be cancelled
|
||||
// from multiple threads.
|
||||
//
|
||||
// - An `ObservationState`: relationship with the `TransactionObserver`
|
||||
// protocol. It is only accessed from the serialized writer
|
||||
// dispatch queue.
|
||||
//
|
||||
// - A `Reducer`: the observation reducer, only accessed from the
|
||||
// serialized dispatch queue `reduceQueue`.
|
||||
//
|
||||
// The `reduceQueue` guarantees that fresh value notifications have the same
|
||||
// order as transactions. It is different from the serialized writer
|
||||
// dispatch queue because we do not want to lock the database as
|
||||
// computations (`map`, `removeDuplicates()`, etc.) are performed.
|
||||
//
|
||||
// Despite being protected by the same lock, `DatabaseAccess` and
|
||||
// `NotificationCallbacks` are not merged together. This is because the
|
||||
// observer does not lose `DatabaseAccess` at the same time it
|
||||
// looses `NotificationCallbacks`:
|
||||
//
|
||||
// - In case of cancellation, `NotificationCallbacks` is lost first, and
|
||||
// `DatabaseAccess` is lost asynchronously, after the observer could
|
||||
// resign as a transaction observer. See `cancel()`.
|
||||
//
|
||||
// - In case of error, `DatabaseAccess` is lost first, and
|
||||
// `NotificationCallbacks` is lost asynchronously, after the error could
|
||||
// be notified. See error catching clauses.
|
||||
|
||||
/// Ability to access the database
|
||||
private struct DatabaseAccess {
|
||||
/// The observed DatabaseWriter.
|
||||
let writer: Writer
|
||||
|
||||
/// If true, database values are fetched from a read-only access.
|
||||
private let readOnly: Bool
|
||||
|
||||
/// A reducer that fetches database values.
|
||||
private let reducer: Reducer
|
||||
|
||||
init(writer: Writer, readOnly: Bool, reducer: Reducer) {
|
||||
self.writer = writer
|
||||
self.readOnly = readOnly
|
||||
self.reducer = reducer
|
||||
}
|
||||
|
||||
func fetch(_ db: Database) throws -> Reducer.Fetched {
|
||||
try db.isolated(readOnly: readOnly) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRecordingObservedRegion(_ db: Database) throws -> (Reducer.Fetched, DatabaseRegion) {
|
||||
var region = DatabaseRegion()
|
||||
let fetchedValue = try db.isolated(readOnly: readOnly) {
|
||||
try db.recordingSelection(®ion) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
return try (fetchedValue, region.observableRegion(db))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ability to notify observation events
|
||||
private struct NotificationCallbacks {
|
||||
let events: ValueObservationEvents
|
||||
let onChange: (Reducer.Value) -> Void
|
||||
}
|
||||
|
||||
/// Relationship with the `TransactionObserver` protocol
|
||||
private struct ObservationState {
|
||||
var region: DatabaseRegion?
|
||||
var isModified = false
|
||||
|
||||
static var notObserving: Self { .init(region: nil, isModified: false) }
|
||||
}
|
||||
|
||||
/// Protects `databaseAccess` and `notificationCallbacks`.
|
||||
private let lock = NSLock()
|
||||
|
||||
/// The dispatch queue where database values are reduced into observed
|
||||
/// values before being notified. Protects `reducer`.
|
||||
private let reduceQueue: DispatchQueue
|
||||
|
||||
/// Access to the database, protected by `lock`.
|
||||
private var databaseAccess: DatabaseAccess?
|
||||
|
||||
/// Ability to notify observation events, protected by `lock`.
|
||||
private var notificationCallbacks: NotificationCallbacks?
|
||||
|
||||
/// Support for `TransactionObserver`, protected by the serialized writer
|
||||
/// dispatch queue.
|
||||
private var observationState = ObservationState.notObserving
|
||||
|
||||
/// Protected by `reduceQueue`.
|
||||
private var reducer: Reducer
|
||||
|
||||
init(
|
||||
writer: Writer,
|
||||
scheduler: Scheduler,
|
||||
readOnly: Bool,
|
||||
trackingMode: ValueObservationTrackingMode,
|
||||
reducer: Reducer,
|
||||
events: ValueObservationEvents,
|
||||
onChange: @escaping (Reducer.Value) -> Void)
|
||||
{
|
||||
// Configuration
|
||||
self.scheduler = scheduler
|
||||
self.trackingMode = trackingMode
|
||||
|
||||
// State
|
||||
self.databaseAccess = DatabaseAccess(
|
||||
writer: writer,
|
||||
readOnly: readOnly,
|
||||
// ValueReducer semantics guarantees that reducer._fetch
|
||||
// is independent from the reducer state
|
||||
reducer: reducer)
|
||||
self.notificationCallbacks = NotificationCallbacks(events: events, onChange: onChange)
|
||||
self.reducer = reducer
|
||||
self.reduceQueue = DispatchQueue(
|
||||
label: writer.configuration.identifier(
|
||||
defaultLabel: "GRDB",
|
||||
purpose: "ValueObservation"),
|
||||
qos: writer.configuration.readQoS)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Starting the Observation
|
||||
|
||||
extension ValueWriteOnlyObserver {
|
||||
// Starts the observation
|
||||
func start() -> AnyDatabaseCancellable {
|
||||
let (notificationCallbacks, writer) = lock.synchronized {
|
||||
(self.notificationCallbacks, self.databaseAccess?.writer)
|
||||
}
|
||||
guard let notificationCallbacks, let writer else {
|
||||
// Likely a GRDB bug: during a synchronous start, user is not
|
||||
// able to cancel observation.
|
||||
fatalError("can't start a cancelled or failed observation")
|
||||
}
|
||||
|
||||
if scheduler.immediateInitialValue() {
|
||||
do {
|
||||
// Start the observation in an synchronous way
|
||||
let initialValue = try syncStart(from: writer)
|
||||
|
||||
// Notify the initial value from the dispatch queue the
|
||||
// observation was started from
|
||||
notificationCallbacks.onChange(initialValue)
|
||||
} catch {
|
||||
// Notify error from the dispatch queue the observation
|
||||
// was started from.
|
||||
notificationCallbacks.events.didFail?(error)
|
||||
|
||||
// Early return!
|
||||
return AnyDatabaseCancellable { /* nothing to cancel */ }
|
||||
}
|
||||
} else {
|
||||
// Start the observation in an asynchronous way
|
||||
asyncStart(from: writer)
|
||||
}
|
||||
|
||||
// Make sure the returned cancellable cancels the observation
|
||||
// when deallocated. We can't relying on the deallocation of
|
||||
// self to trigger early cancellation, because self may be retained by
|
||||
// some closure waiting to run in some DispatchQueue.
|
||||
return AnyDatabaseCancellable(self)
|
||||
}
|
||||
|
||||
/// Synchronously starts the observation, and returns the initial value.
|
||||
///
|
||||
/// Unlike `asyncStart()`, this method does not notify the initial value or error.
|
||||
private func syncStart(from writer: Writer) throws -> Reducer.Value {
|
||||
// Start from a write access, so that self can register as a
|
||||
// transaction observer.
|
||||
//
|
||||
// Start in a synchronous reentrant way, in case this method is called
|
||||
// from a database access.
|
||||
try writer.unsafeReentrantWrite { db in
|
||||
// Fetch & Start observing the database
|
||||
guard let fetchedValue = try fetchAndStartObservation(db) else {
|
||||
// Likely a GRDB bug: during a synchronous start, user is not
|
||||
// able to cancel observation.
|
||||
fatalError("can't start a cancelled or failed observation")
|
||||
}
|
||||
|
||||
// Reduce
|
||||
return try reduceQueue.sync {
|
||||
guard let initialValue = try reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
return initialValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asynchronously starts the observation
|
||||
///
|
||||
/// Unlike `syncStart()`, this method does notify the initial value or error.
|
||||
private func asyncStart(from writer: Writer) {
|
||||
// Start from a write access, so that self can register as a
|
||||
// transaction observer.
|
||||
writer.asyncWriteWithoutTransaction { db in
|
||||
do {
|
||||
// Fetch & Start observing the database
|
||||
guard let fetchedValue = try self.fetchAndStartObservation(db) else {
|
||||
return /* Cancelled */
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering
|
||||
// between transactions and notifications!
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
guard let initialValue = try self.reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
// Notify
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(initialValue)
|
||||
}
|
||||
} catch {
|
||||
let writer = self.lock.synchronized { self.databaseAccess?.writer }
|
||||
writer?.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.stopDatabaseObservation(db)
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the initial value, and start observing the database.
|
||||
///
|
||||
/// Returns nil if the observation was cancelled before database observation
|
||||
/// could start.
|
||||
///
|
||||
/// By grouping the initial fetch and the beginning of observation in a
|
||||
/// single database access, we are sure that no concurrent write can happen
|
||||
/// during the initial fetch, and that we won't miss any future change.
|
||||
private func fetchAndStartObservation(_ db: Database) throws -> Reducer.Fetched? {
|
||||
let (events, databaseAccess) = lock.synchronized {
|
||||
(notificationCallbacks?.events, self.databaseAccess)
|
||||
}
|
||||
guard let events, let databaseAccess else {
|
||||
return nil /* Cancelled */
|
||||
}
|
||||
|
||||
switch trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
let fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
let observedRegion = try region.observableRegion(db)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
startObservation(db, observedRegion: observedRegion)
|
||||
return fetchedValue
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
let (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
startObservation(db, observedRegion: observedRegion)
|
||||
return fetchedValue
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservation(_ db: Database, observedRegion: DatabaseRegion) {
|
||||
observationState.region = observedRegion
|
||||
assert(observationState.isModified == false)
|
||||
db.add(transactionObserver: self, extent: .observerLifetime)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observing Database Transactions
|
||||
|
||||
extension ValueWriteOnlyObserver: TransactionObserver {
|
||||
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
|
||||
if let region = observationState.region {
|
||||
return region.isModified(byEventsOfKind: eventKind)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidChange() {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
|
||||
func databaseDidChange(with event: DatabaseEvent) {
|
||||
if let region = observationState.region, region.isModified(by: event) {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidCommit(_ db: Database) {
|
||||
// Ignore transaction unless database was modified
|
||||
guard observationState.isModified else { return }
|
||||
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
|
||||
// Ignore transaction unless we are still notifying database events, and
|
||||
// we can still fetch fresh values.
|
||||
let (events, databaseAccess) = lock.synchronized {
|
||||
(notificationCallbacks?.events, self.databaseAccess)
|
||||
}
|
||||
guard let events, let databaseAccess else { return /* Cancelled */ }
|
||||
|
||||
// Notify
|
||||
events.databaseDidChange?()
|
||||
|
||||
do {
|
||||
// Fetch
|
||||
let fetchedValue: Reducer.Fetched
|
||||
|
||||
switch trackingMode {
|
||||
case .constantRegion, .constantRegionRecordedFromSelection:
|
||||
// Tracked region is already known. Fetch only.
|
||||
fetchedValue = try databaseAccess.fetch(db)
|
||||
case .nonConstantRegionRecordedFromSelection:
|
||||
// Fetch and update the tracked region.
|
||||
let (value, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
fetchedValue = value
|
||||
|
||||
// Don't spam the user with region tracking events: wait for an actual change
|
||||
if let willTrackRegion = events.willTrackRegion, observedRegion != observationState.region {
|
||||
willTrackRegion(observedRegion)
|
||||
}
|
||||
|
||||
observationState.region = observedRegion
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering between
|
||||
// transactions and notifications!
|
||||
reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
let value = try self.reducer._value(fetchedValue)
|
||||
|
||||
// Notify value
|
||||
if let value {
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let writer = self.lock.synchronized { self.databaseAccess?.writer }
|
||||
writer?.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
stopDatabaseObservation(db)
|
||||
notifyError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidRollback(_ db: Database) {
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ending the Observation
|
||||
|
||||
extension ValueWriteOnlyObserver: DatabaseCancellable {
|
||||
func cancel() {
|
||||
// Notify cancellation
|
||||
let (events, writer) = lock.synchronized {
|
||||
let events = notificationCallbacks?.events
|
||||
// Set callbacks to nil so that we can't notify anything after
|
||||
// the cancellation.
|
||||
notificationCallbacks = nil
|
||||
return (events, databaseAccess?.writer)
|
||||
}
|
||||
|
||||
guard let events else { return /* Cancelled or failed */ }
|
||||
events.didCancel?()
|
||||
|
||||
// Stop observing the database
|
||||
// Do it asynchronously, so that we do not block the current thread:
|
||||
// cancellation may be triggered while a long write access is executing.
|
||||
guard let writer else { return /* Failed */ }
|
||||
writer.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyError(_ error: Error) {
|
||||
scheduler.schedule {
|
||||
let events = self.lock.synchronized {
|
||||
let events = self.notificationCallbacks?.events
|
||||
self.notificationCallbacks = nil
|
||||
return events
|
||||
}
|
||||
guard let events else { return /* Cancelled */ }
|
||||
events.didFail?(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDatabaseObservation(_ db: Database) {
|
||||
db.remove(transactionObserver: self)
|
||||
observationState = .notObserving
|
||||
lock.synchronized {
|
||||
databaseAccess = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
extension ValueReducers {
|
||||
/// A `ValueReducer` that perform database fetches.
|
||||
public struct Fetch<Value>: ValueReducer {
|
||||
private let __fetch: (Database) throws -> Value
|
||||
|
||||
/// Creates a reducer which passes raw fetched values through.
|
||||
init(fetch: @escaping (Database) throws -> Value) {
|
||||
self.__fetch = fetch
|
||||
}
|
||||
|
||||
public func _fetch(_ db: Database) throws -> Value {
|
||||
assert(db.isInsideTransaction, "Fetching in a non-isolated way is illegal")
|
||||
return try __fetch(db)
|
||||
}
|
||||
|
||||
public func _value(_ fetched: Value) -> Value? {
|
||||
fetched
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
extension ValueObservation {
|
||||
/// Transforms all values from the upstream observation with a
|
||||
/// provided closure.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Turn an observation of Player? into an observation of UIImage?
|
||||
/// let observation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchOne(db, id: 42) }
|
||||
/// .map { player in player?.image }
|
||||
/// ```
|
||||
///
|
||||
/// The `transform` closure does not run on the main thread, and does not
|
||||
/// block any database access This makes the `map` operator a tool that
|
||||
/// helps reducing database contention
|
||||
/// (see <doc:ValueObservation#ValueObservation-Performance>).
|
||||
///
|
||||
/// - parameter transform: A closure that takes one value as its parameter
|
||||
/// and returns a new value.
|
||||
public func map<T>(_ transform: @escaping (Reducer.Value) throws -> T)
|
||||
-> ValueObservation<ValueReducers.Map<Reducer, T>>
|
||||
{
|
||||
mapReducer { ValueReducers.Map($0, transform) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueReducers {
|
||||
/// A `ValueReducer` whose values consist of those in a `Base` reduced
|
||||
/// passed through a transform function.
|
||||
///
|
||||
/// See ``ValueObservation/map(_:)``.
|
||||
public struct Map<Base: _ValueReducer, Value>: _ValueReducer {
|
||||
private var base: Base
|
||||
private let transform: (Base.Value) throws -> Value
|
||||
|
||||
init(_ base: Base, _ transform: @escaping (Base.Value) throws -> Value) {
|
||||
self.base = base
|
||||
self.transform = transform
|
||||
}
|
||||
|
||||
public mutating func _value(_ fetched: Base.Fetched) throws -> Value? {
|
||||
guard let value = try base._value(fetched) else { return nil }
|
||||
return try transform(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueReducers.Map: ValueReducer where Base: ValueReducer {
|
||||
public func _fetch(_ db: Database) throws -> Base.Fetched {
|
||||
try base._fetch(db)
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
extension ValueObservation {
|
||||
/// Notifies only values that don’t match the previously observed value, as
|
||||
/// evaluated by a provided closure.
|
||||
///
|
||||
/// - parameter predicate: A closure to evaluate whether two values are
|
||||
/// equivalent, for purposes of filtering. Return true from this closure
|
||||
/// to indicate that the second element is a duplicate of the first.
|
||||
public func removeDuplicates(by predicate: @escaping (Reducer.Value, Reducer.Value) -> Bool)
|
||||
-> ValueObservation<ValueReducers.RemoveDuplicates<Reducer>>
|
||||
{
|
||||
mapReducer { ValueReducers.RemoveDuplicates($0, predicate: predicate) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueObservation where Reducer.Value: Equatable {
|
||||
/// Notifies only values that don’t match the previously observed value.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // An observation of distinct Player?
|
||||
/// let observation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchOne(db, id: 42) }
|
||||
/// .removeDuplicates()
|
||||
/// ```
|
||||
///
|
||||
/// > Tip: When the observed value does not adopt `Equatable`, and it is
|
||||
/// > impractical to provide a custom comparison function, you can observe
|
||||
/// > distinct raw database values such as ``Row`` or ``DatabaseValue``,
|
||||
/// > before converting them to the desired type. For example, the previous
|
||||
/// > observation can be rewritten as below:
|
||||
/// >
|
||||
/// > ```swift
|
||||
/// > // An observation of distinct `Player?`
|
||||
/// > let request = Player.filter(id: 42)
|
||||
/// > let observation = ValueObservation
|
||||
/// > .tracking { db in try Row.fetchOne(db, request) }
|
||||
/// > .removeDuplicates()
|
||||
/// > .map { row in try row.map(Player.init(row:)) }
|
||||
/// > ```
|
||||
/// >
|
||||
/// > This technique is also available for requests that
|
||||
/// > involve associations:
|
||||
/// >
|
||||
/// > ```swift
|
||||
/// > struct TeamInfo: Decodable, FetchableRecord {
|
||||
/// > var team: Team
|
||||
/// > var players: [Player]
|
||||
/// > }
|
||||
/// >
|
||||
/// > // An observation of distinct `[TeamInfo]`
|
||||
/// > let request = Team.including(all: Team.players)
|
||||
/// > let observation = ValueObservation
|
||||
/// > .tracking { db in try Row.fetchAll(db, request) }
|
||||
/// > .removeDuplicates() // Row adopts Equatable
|
||||
/// > .map { rows in try rows.map(TeamInfo.init(row:)) }
|
||||
/// > ```
|
||||
public func removeDuplicates()
|
||||
-> ValueObservation<ValueReducers.RemoveDuplicates<Reducer>>
|
||||
{
|
||||
mapReducer { ValueReducers.RemoveDuplicates($0, predicate: ==) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueReducers {
|
||||
/// A `ValueReducer` that notifies only values that don’t match the
|
||||
/// previously observed value.
|
||||
///
|
||||
/// See ``ValueObservation/removeDuplicates()``.
|
||||
public struct RemoveDuplicates<Base: _ValueReducer>: _ValueReducer {
|
||||
private var base: Base
|
||||
private var previousValue: Base.Value?
|
||||
private var predicate: (Base.Value, Base.Value) -> Bool
|
||||
|
||||
init(_ base: Base, predicate: @escaping (Base.Value, Base.Value) -> Bool) {
|
||||
self.base = base
|
||||
self.predicate = predicate
|
||||
}
|
||||
|
||||
public mutating func _value(_ fetched: Base.Fetched) throws -> Base.Value? {
|
||||
guard let value = try base._value(fetched) else {
|
||||
return nil
|
||||
}
|
||||
if let previousValue, predicate(previousValue, value) {
|
||||
// Don't notify consecutive identical values
|
||||
return nil
|
||||
}
|
||||
self.previousValue = value
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueReducers.RemoveDuplicates: ValueReducer where Base: ValueReducer {
|
||||
public func _fetch(_ db: Database) throws -> Base.Fetched {
|
||||
try base._fetch(db)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
extension ValueReducers {
|
||||
// swiftlint:disable line_length
|
||||
/// A `ValueReducer` that handles ``ValueObservation`` events.
|
||||
///
|
||||
/// See ``ValueObservation/handleEvents(willStart:willFetch:willTrackRegion:databaseDidChange:didReceiveValue:didFail:didCancel:)``
|
||||
/// and ``ValueObservation/print(_:to:)``.
|
||||
public struct Trace<Base: _ValueReducer>: _ValueReducer {
|
||||
var base: Base
|
||||
let willFetch: () -> Void
|
||||
let didReceiveValue: (Base.Value) -> Void
|
||||
|
||||
public mutating func _value(_ fetched: Base.Fetched) throws -> Base.Value? {
|
||||
guard let value = try base._value(fetched) else {
|
||||
return nil
|
||||
}
|
||||
didReceiveValue(value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
|
||||
extension ValueReducers.Trace: ValueReducer where Base: ValueReducer {
|
||||
public func _fetch(_ db: Database) throws -> Base.Fetched {
|
||||
willFetch()
|
||||
return try base._fetch(db)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/// Implementation details of `ValueReducer`.
|
||||
public protocol _ValueReducer {
|
||||
/// The type of fetched database values
|
||||
associatedtype Fetched
|
||||
|
||||
/// The type of observed values
|
||||
associatedtype Value
|
||||
|
||||
/// Transforms a fetched value into an eventual observed value. Returns nil
|
||||
/// when observer should not be notified.
|
||||
///
|
||||
/// This method runs in some unspecified dispatch queue.
|
||||
///
|
||||
/// ValueReducer semantics require that the first invocation of this
|
||||
/// method returns a non-nil value:
|
||||
///
|
||||
/// let reducer = MyReducer()
|
||||
/// reducer._value(...) // MUST NOT be nil
|
||||
/// reducer._value(...) // MAY be nil
|
||||
/// reducer._value(...) // MAY be nil
|
||||
mutating func _value(_ fetched: Fetched) throws -> Value?
|
||||
}
|
||||
|
||||
/// `ValueReducer` supports ``ValueObservation``.
|
||||
///
|
||||
/// A `ValueReducer` fetches and transforms the database values
|
||||
/// observed by a ``ValueObservation``.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Support
|
||||
///
|
||||
/// - ``ValueReducers``
|
||||
public protocol ValueReducer: _ValueReducer {
|
||||
/// Fetches database values upon changes in an observed database region.
|
||||
///
|
||||
/// This method must does not depend on the state of the reducer.
|
||||
func _fetch(_ db: Database) throws -> Fetched
|
||||
}
|
||||
|
||||
/// A namespace for concrete types that adopt the ``ValueReducer`` protocol.
|
||||
public enum ValueReducers { }
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import Foundation
|
||||
|
||||
/// The extent of the shared subscription to a ``SharedValueObservation``.
|
||||
public enum SharedValueObservationExtent: Sendable {
|
||||
/// The ``SharedValueObservation`` starts a single database observation,
|
||||
/// which stops when the `SharedValueObservation` is deallocated and all
|
||||
/// subscriptions terminated.
|
||||
///
|
||||
/// This extent prevents the shared observation from recovering from
|
||||
/// database errors. To recover from database errors, you must create a new
|
||||
/// shared `SharedValueObservation` instance.
|
||||
case observationLifetime
|
||||
|
||||
/// The ``SharedValueObservation`` stops database observation when the
|
||||
/// number of subscriptions drops down to zero. Database observation
|
||||
/// restarts on the next subscription.
|
||||
///
|
||||
/// Database errors can be recovered by resubscribing to the
|
||||
/// shared observation.
|
||||
case whileObserved
|
||||
}
|
||||
|
||||
extension ValueObservation {
|
||||
/// Returns a shared value observation that spares database resources by
|
||||
/// sharing a single underlying ``ValueObservation`` subscription.
|
||||
///
|
||||
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let sharedObservation = observation.shared(in: dbQueue)
|
||||
///
|
||||
/// let cancellable = try sharedObservation.start { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The underlying subscription is shared if and only if you start observing
|
||||
/// the database from the same `SharedValueObservation` instance:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Shared
|
||||
/// let sharedObservation = ValueObservation.tracking { db in ... }.shared(in: dbQueue)
|
||||
/// let cancellable1 = sharedObservation.start(...)
|
||||
/// let cancellable2 = sharedObservation.start(...)
|
||||
///
|
||||
/// // NOT shared
|
||||
/// let cancellable1 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...)
|
||||
/// let cancellable2 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...)
|
||||
/// ```
|
||||
///
|
||||
/// A shared observation starts observing the database as soon as it is
|
||||
/// subscribed. You can choose if database observation should stop, or not,
|
||||
/// when its number of subscriptions drops down to zero, with the `extent`
|
||||
/// parameter:
|
||||
///
|
||||
/// ```swift
|
||||
/// // The default: stops observing the database when the number of
|
||||
/// // subscriptions drops down to zero, and restart database observation
|
||||
/// // on the next subscription.
|
||||
/// //
|
||||
/// // Database errors can be recovered by resubscribing to the
|
||||
/// // shared observation.
|
||||
/// let sharedObservation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchAll(db) }
|
||||
/// .shared(in: dbQueue, extent: .whileObserved)
|
||||
///
|
||||
/// // Only stops observing the database when the shared observation
|
||||
/// // is deinitialized, and all subscriptions are cancelled.
|
||||
/// //
|
||||
/// // This extent prevents the shared observation from recovering
|
||||
/// // from database errors. To recover from database errors, create a new
|
||||
/// // shared SharedValueObservation instance.
|
||||
/// let sharedObservation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchAll(db) }
|
||||
/// .shared(in: dbQueue, extent: .observationLifetime)
|
||||
/// ```
|
||||
///
|
||||
/// By default, fresh values are dispatched asynchronously on the
|
||||
/// main dispatch queue. You can change this behavior by providing a
|
||||
/// scheduler.
|
||||
///
|
||||
/// For example, the ``ValueObservationScheduler/immediate`` scheduler
|
||||
/// notifies all values on the main dispatch queue, and notifies the first
|
||||
/// one immediately when the
|
||||
/// ``SharedValueObservation/start(onError:onChange:)`` method is called.
|
||||
/// The `immediate` scheduling requires that the observation starts from the
|
||||
/// main thread (a fatal error is raised otherwise):
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let sharedObservation = observation.shared(
|
||||
/// in: dbQueue,
|
||||
/// scheduling: .immediate)
|
||||
///
|
||||
/// let cancellable = try sharedObservation.start { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// // <- here "Fresh players" is already printed.
|
||||
/// ```
|
||||
///
|
||||
/// Note that the `.immediate` scheduler requires that the observation is
|
||||
/// subscribed from the main thread. It raises a fatal error otherwise.
|
||||
///
|
||||
/// - parameter reader: A DatabaseReader.
|
||||
/// - parameter scheduler: A Scheduler. By default, fresh values are
|
||||
/// dispatched asynchronously on the main queue.
|
||||
/// - parameter extent: The extent of the shared database observation.
|
||||
/// - returns: A `SharedValueObservation`
|
||||
public func shared(
|
||||
in reader: some DatabaseReader,
|
||||
scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main),
|
||||
extent: SharedValueObservationExtent = .whileObserved)
|
||||
-> SharedValueObservation<Reducer.Value>
|
||||
where Reducer: ValueReducer
|
||||
{
|
||||
SharedValueObservation(scheduling: scheduler, extent: extent) { onError, onChange in
|
||||
self.start(in: reader, scheduling: scheduler, onError: onError, onChange: onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared value observation spares database resources by sharing a single
|
||||
/// underlying ``ValueObservation`` subscription.
|
||||
///
|
||||
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
||||
///
|
||||
/// You build a `SharedValueObservation` with the ``ValueObservation`` method
|
||||
/// ``ValueObservation/shared(in:scheduling:extent:)``. For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let sharedObservation = observation.shared(in: dbQueue)
|
||||
///
|
||||
/// let cancellable = try sharedObservation.start { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The underlying subscription is shared if and only if you start observing
|
||||
/// the database from the same `SharedValueObservation` instance:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Shared
|
||||
/// let sharedObservation = ValueObservation.tracking { db in ... }.shared(in: dbQueue)
|
||||
/// let cancellable1 = sharedObservation.start(...)
|
||||
/// let cancellable2 = sharedObservation.start(...)
|
||||
///
|
||||
/// // NOT shared
|
||||
/// let cancellable1 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...)
|
||||
/// let cancellable2 = ValueObservation.tracking { db in ... }.shared(in: dbQueue).start(...)
|
||||
/// ```
|
||||
public final class SharedValueObservation<Element> {
|
||||
private let scheduler: any ValueObservationScheduler
|
||||
private let extent: SharedValueObservationExtent
|
||||
private let startObservation: ValueObservationStart<Element>
|
||||
private let lock = NSRecursiveLock() // support synchronous observation events
|
||||
|
||||
// protected by lock
|
||||
private var clients: [Client]
|
||||
private var isObserving = false
|
||||
private var cancellable: AnyDatabaseCancellable?
|
||||
private var lastResult: Result<Element, Error>?
|
||||
|
||||
private final class Client {
|
||||
var onError: (Error) -> Void
|
||||
var onChange: (Element) -> Void
|
||||
|
||||
init(onError: @escaping (Error) -> Void, onChange: @escaping (Element) -> Void) {
|
||||
self.onError = onError
|
||||
self.onChange = onChange
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate init(
|
||||
scheduling scheduler: some ValueObservationScheduler,
|
||||
extent: SharedValueObservationExtent,
|
||||
startObservation: @escaping ValueObservationStart<Element>)
|
||||
{
|
||||
self.scheduler = scheduler
|
||||
self.extent = extent
|
||||
self.startObservation = startObservation
|
||||
self.clients = []
|
||||
}
|
||||
|
||||
/// Starts observing the database.
|
||||
///
|
||||
/// The observation lasts until the returned cancellable is cancelled
|
||||
/// or deallocated.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let sharedObservation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchAll(db) }
|
||||
/// .shared(in: dbQueue)
|
||||
///
|
||||
/// let cancellable = try sharedObservation.start { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter onError: The closure to execute when the observation fails.
|
||||
/// - parameter onChange: The closure to execute on receipt of a
|
||||
/// fresh value.
|
||||
/// - returns: A DatabaseCancellable that can stop the observation.
|
||||
public func start(
|
||||
onError: @escaping (Error) -> Void,
|
||||
onChange: @escaping (Element) -> Void)
|
||||
-> AnyDatabaseCancellable
|
||||
{
|
||||
synchronized {
|
||||
// Support for reentrancy: a shared immediate observation is
|
||||
// started from the first value notification of that same shared
|
||||
// immediate observation. Yeah, users are nasty.
|
||||
// In this case, self.cancellable is still nil, because we are
|
||||
// still waiting for the upstream ValueObservation to start.
|
||||
// But we must not start another one.
|
||||
let needsStart = !isObserving
|
||||
|
||||
// State change
|
||||
let client = Client(onError: onError, onChange: onChange)
|
||||
clients.append(client)
|
||||
isObserving = true
|
||||
|
||||
// Side effect
|
||||
if needsStart {
|
||||
// Self retains the cancellable, so don't have the cancellable retain self.
|
||||
cancellable = startObservation(
|
||||
// onError
|
||||
{ [weak self] error in
|
||||
self?.handleError(error)
|
||||
},
|
||||
// onChange
|
||||
{ [weak self] element in
|
||||
self?.handleChange(element)
|
||||
})
|
||||
} else if let result = lastResult {
|
||||
// Notify last result as an initial value
|
||||
scheduler.scheduleInitial {
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
onError(error)
|
||||
case let .success(value):
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AnyDatabaseCancellable {
|
||||
// Retain shared observation (self) until client cancels
|
||||
self.handleCancel(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Combine)
|
||||
/// Returns a publisher of observed values.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchAll(db) }
|
||||
/// .shared(in: dbQueue)
|
||||
///
|
||||
/// let publisher = observation.publisher()
|
||||
///
|
||||
/// let cancellable = publisher.sink { completion in
|
||||
/// // handle completion
|
||||
/// } receiveValue: { (players: [Player]) in
|
||||
/// print("fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func publisher() -> DatabasePublishers.Value<Element> {
|
||||
DatabasePublishers.Value { onError, onChange in
|
||||
self.start(onError: onError, onChange: onChange)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func handleError(_ error: Error) {
|
||||
synchronized {
|
||||
let notifiedClients = clients
|
||||
|
||||
// State change
|
||||
clients = []
|
||||
if extent == .whileObserved {
|
||||
isObserving = false
|
||||
cancellable = nil
|
||||
lastResult = nil
|
||||
} else {
|
||||
lastResult = .failure(error)
|
||||
}
|
||||
|
||||
// Side effect
|
||||
for client in notifiedClients {
|
||||
client.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleChange(_ value: Element) {
|
||||
synchronized {
|
||||
// State change
|
||||
lastResult = .success(value)
|
||||
|
||||
// Side effect
|
||||
for client in clients {
|
||||
client.onChange(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCancel(_ client: Client) {
|
||||
synchronized {
|
||||
// State change
|
||||
clients.removeFirst(where: { $0 === client })
|
||||
if clients.isEmpty && extent == .whileObserved {
|
||||
isObserving = false
|
||||
cancellable = nil
|
||||
lastResult = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronized<T>(_ execute: () throws -> T) rethrows -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return try execute()
|
||||
}
|
||||
}
|
||||
|
||||
extension SharedValueObservation {
|
||||
// MARK: - Asynchronous Observation
|
||||
/// Returns an asynchronous sequence of observed values.
|
||||
///
|
||||
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let sharedObservation = ValueObservation
|
||||
/// .tracking { db in try Player.fetchAll(db) }
|
||||
/// .shared(in: dbQueue)
|
||||
///
|
||||
/// for try await players in sharedObservation.values() {
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func values(bufferingPolicy: AsyncValueObservation<Element>.BufferingPolicy = .unbounded)
|
||||
-> AsyncValueObservation<Element>
|
||||
{
|
||||
AsyncValueObservation(bufferingPolicy: bufferingPolicy) { onError, onChange in
|
||||
self.start(onError: onError, onChange: onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
#endif
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
public struct ValueObservation<Reducer: _ValueReducer> {
|
||||
var events = ValueObservationEvents()
|
||||
|
||||
/// A boolean value indicating whether the observation requires write access
|
||||
/// when it fetches fresh values.
|
||||
///
|
||||
/// The `requiresWriteAccess` property is false by default. When true, a
|
||||
/// `ValueObservation` has a write access to the database, and its fetches
|
||||
/// are automatically wrapped in a savepoint:
|
||||
///
|
||||
/// ```swift
|
||||
/// var observation = ValueObservation.tracking { db in
|
||||
/// // write access allowed
|
||||
/// ...
|
||||
/// }
|
||||
/// observation.requiresWriteAccess = true
|
||||
/// ```
|
||||
///
|
||||
/// Setting the `requiresWriteAccess` flag can disable scheduling
|
||||
/// optimizations when the observation is started in a ``DatabasePool``.
|
||||
public var requiresWriteAccess = false
|
||||
|
||||
var trackingMode: ValueObservationTrackingMode
|
||||
|
||||
/// The reducer is created when observation starts, and is triggered upon
|
||||
/// each database change.
|
||||
var makeReducer: () -> Reducer
|
||||
|
||||
/// Returns a ValueObservation with a transformed reducer.
|
||||
func mapReducer<R>(_ transform: @escaping (Reducer) -> R) -> ValueObservation<R> {
|
||||
let makeReducer = self.makeReducer
|
||||
return ValueObservation<R>(
|
||||
events: events,
|
||||
requiresWriteAccess: requiresWriteAccess,
|
||||
trackingMode: trackingMode,
|
||||
makeReducer: { transform(makeReducer()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the tracked region
|
||||
enum ValueObservationTrackingMode {
|
||||
/// The tracked region is constant and explicit.
|
||||
///
|
||||
/// Use case:
|
||||
///
|
||||
/// // Tracked Region is always the full player table
|
||||
/// ValueObservation.trackingConstantRegion(Player.all()) { db in ... }
|
||||
case constantRegion([any DatabaseRegionConvertible])
|
||||
|
||||
/// The tracked region is constant and inferred from the fetched values.
|
||||
///
|
||||
/// Use case:
|
||||
///
|
||||
/// // Tracked Region is always the full player table
|
||||
/// ValueObservation.trackingConstantRegion { db in Player.fetchAll(db) }
|
||||
case constantRegionRecordedFromSelection
|
||||
|
||||
/// The tracked region is not constant, and inferred from the fetched values.
|
||||
///
|
||||
/// Use case:
|
||||
///
|
||||
/// // Tracked Region is the one row of the table, and it changes on
|
||||
/// // each fetch.
|
||||
/// ValueObservation.tracking { db in
|
||||
/// try Player.fetchOne(db, id: Int.random(in: 1.1000))
|
||||
/// }
|
||||
case nonConstantRegionRecordedFromSelection
|
||||
}
|
||||
|
||||
struct ValueObservationEvents: Refinable {
|
||||
var willStart: (() -> Void)?
|
||||
var willTrackRegion: ((DatabaseRegion) -> Void)?
|
||||
var databaseDidChange: (() -> Void)?
|
||||
var didFail: ((Error) -> Void)?
|
||||
var didCancel: (() -> Void)?
|
||||
}
|
||||
|
||||
typealias ValueObservationStart<T> = (
|
||||
_ onError: @escaping (Error) -> Void,
|
||||
_ onChange: @escaping (T) -> Void)
|
||||
-> AnyDatabaseCancellable
|
||||
|
||||
extension ValueObservation: Refinable {
|
||||
|
||||
// MARK: - Starting Observation
|
||||
|
||||
/// Starts observing the database.
|
||||
///
|
||||
/// The observation lasts until the returned cancellable is cancelled
|
||||
/// or deallocated.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let cancellable = try observation.start(in: dbQueue) { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// By default, fresh values are dispatched asynchronously on the
|
||||
/// main dispatch queue. You can change this behavior by providing a
|
||||
/// scheduler.
|
||||
///
|
||||
/// For example, the ``ValueObservationScheduler/immediate`` scheduler
|
||||
/// notifies all values on the main dispatch queue, and notifies the first
|
||||
/// one immediately when the observation starts. The `immediate` scheduling
|
||||
/// requires that the observation starts from the main dispatch queue (a
|
||||
/// fatal error is raised otherwise):
|
||||
///
|
||||
/// ```swift
|
||||
/// let cancellable = try observation.start(in: dbQueue, scheduling: .immediate) { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// // <- here "Fresh players" is already printed.
|
||||
/// ```
|
||||
///
|
||||
/// - parameter reader: A DatabaseReader.
|
||||
/// - parameter scheduler: A ValueObservationScheduler. By default, fresh
|
||||
/// values are dispatched asynchronously on the main queue.
|
||||
/// - parameter onError: The closure to execute when the observation fails.
|
||||
/// - parameter onChange: The closure to execute on receipt of a
|
||||
/// fresh value.
|
||||
/// - returns: A DatabaseCancellable that can stop the observation.
|
||||
public func start(
|
||||
in reader: some DatabaseReader,
|
||||
scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main),
|
||||
onError: @escaping (Error) -> Void,
|
||||
onChange: @escaping (Reducer.Value) -> Void)
|
||||
-> AnyDatabaseCancellable
|
||||
where Reducer: ValueReducer
|
||||
{
|
||||
let observation = self.with {
|
||||
$0.events.didFail = concat($0.events.didFail, onError)
|
||||
}
|
||||
observation.events.willStart?()
|
||||
return reader._add(
|
||||
observation: observation,
|
||||
scheduling: scheduler,
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
// MARK: - Debugging
|
||||
|
||||
/// Performs the specified closures when observation events occur.
|
||||
///
|
||||
/// All closures run on unspecified dispatch queues: don't make
|
||||
/// any assumption.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - willStart: The closure to execute when the observation starts.
|
||||
/// - willFetch: The closure to execute when the observed value is
|
||||
/// about to be fetched.
|
||||
/// - willTrackRegion: The closure to execute when the observation
|
||||
/// starts tracking a database region.
|
||||
/// - databaseDidChange: The closure to execute after the observation
|
||||
/// was impacted by a database change.
|
||||
/// - didReceiveValue: The closure to execute on fresh values.
|
||||
/// - didFail: The closure to execute when the observation fails.
|
||||
/// - didCancel: The closure to execute when the observation is
|
||||
/// cancelled.
|
||||
/// - returns: A `ValueObservation` that performs the specified closures
|
||||
/// when ValueObservation events occur.
|
||||
public func handleEvents(
|
||||
willStart: (() -> Void)? = nil,
|
||||
willFetch: (() -> Void)? = nil,
|
||||
willTrackRegion: ((DatabaseRegion) -> Void)? = nil,
|
||||
databaseDidChange: (() -> Void)? = nil,
|
||||
didReceiveValue: ((Reducer.Value) -> Void)? = nil,
|
||||
didFail: ((Error) -> Void)? = nil,
|
||||
didCancel: (() -> Void)? = nil)
|
||||
-> ValueObservation<ValueReducers.Trace<Reducer>>
|
||||
{
|
||||
self
|
||||
.mapReducer { reducer in
|
||||
ValueReducers.Trace(
|
||||
base: reducer,
|
||||
// Adding the willFetch handler to the reducer is handy: we
|
||||
// are sure not to miss any fetch.
|
||||
willFetch: willFetch ?? { },
|
||||
// Adding the didReceiveValue handler to the reducer is necessary:
|
||||
// the type of the value may change with the `map` operator.
|
||||
didReceiveValue: didReceiveValue ?? { _ in })
|
||||
}
|
||||
.with {
|
||||
$0.events.willStart = concat($0.events.willStart, willStart)
|
||||
$0.events.willTrackRegion = concat($0.events.willTrackRegion, willTrackRegion)
|
||||
$0.events.databaseDidChange = concat($0.events.databaseDidChange, databaseDidChange)
|
||||
$0.events.didFail = concat($0.events.didFail, didFail)
|
||||
$0.events.didCancel = concat($0.events.didCancel, didCancel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints log messages for all observation events.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let cancellable = ValueObservation
|
||||
/// .tracking(Player.fetchCount)
|
||||
/// .print("Observe player count")
|
||||
/// .start(in: dbQueue, onError: { _ in }, onChange: { _ in })
|
||||
///
|
||||
/// // Prints:
|
||||
/// // Observe player count: start
|
||||
/// // Observe player count: fetch
|
||||
/// // Observe player count: tracked region: player(*)
|
||||
/// // Observe player count: value: 0
|
||||
/// // Observe player count: database did change
|
||||
/// // Observe player count: fetch
|
||||
/// // Observe player count: value: 1
|
||||
/// ```
|
||||
///
|
||||
/// - parameter prefix: A string —- which defaults to empty -— with which to
|
||||
/// prefix all log messages.
|
||||
/// - parameter stream: A stream for text output that receives messages, and
|
||||
/// which directs output to the console by default. A custom stream can be
|
||||
/// used to log messages to other destinations.
|
||||
public func print(
|
||||
_ prefix: String = "",
|
||||
to stream: TextOutputStream? = nil)
|
||||
-> ValueObservation<ValueReducers.Trace<Reducer>>
|
||||
{
|
||||
let lock = NSLock()
|
||||
let prefix = prefix.isEmpty ? "" : "\(prefix): "
|
||||
var stream = stream ?? PrintOutputStream()
|
||||
return handleEvents(
|
||||
willStart: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)start") },
|
||||
willFetch: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)fetch") },
|
||||
willTrackRegion: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)tracked region: \($0)") },
|
||||
databaseDidChange: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)database did change") },
|
||||
didReceiveValue: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)value: \($0)") },
|
||||
didFail: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)failure: \($0)") },
|
||||
didCancel: {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
stream.write("\(prefix)cancel") })
|
||||
}
|
||||
|
||||
// MARK: - Fetching Values
|
||||
|
||||
/// Fetches the initial value.
|
||||
func fetchInitialValue(_ db: Database) throws -> Reducer.Value
|
||||
where Reducer: ValueReducer
|
||||
{
|
||||
var reducer = makeReducer()
|
||||
guard let value = try reducer._value(reducer._fetch(db)) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueObservation {
|
||||
// MARK: - Asynchronous Observation
|
||||
/// Returns an asynchronous sequence of observed values.
|
||||
///
|
||||
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// for try await players in observation.values(in: dbQueue) {
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter reader: A DatabaseReader.
|
||||
/// - parameter scheduler: A ValueObservationScheduler. By default, fresh
|
||||
/// values are dispatched asynchronously on the main dispatch queue.
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func values(
|
||||
in reader: some DatabaseReader,
|
||||
scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main),
|
||||
bufferingPolicy: AsyncValueObservation<Reducer.Value>.BufferingPolicy = .unbounded)
|
||||
-> AsyncValueObservation<Reducer.Value>
|
||||
where Reducer: ValueReducer
|
||||
{
|
||||
AsyncValueObservation(bufferingPolicy: bufferingPolicy) { onError, onChange in
|
||||
self.start(in: reader, scheduling: scheduler, onError: onError, onChange: onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: [GRDB7] Make it Sendable for easier integration with AsyncAlgorithms
|
||||
/// An asynchronous sequence of values observed by a ``ValueObservation``.
|
||||
///
|
||||
/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features)
|
||||
///
|
||||
/// An `AsyncValueObservation` sequence produces a fresh value whenever the
|
||||
/// results of database requests change.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// for try await players in observation.values(in: dbQueue) {
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// You build an `AsyncValueObservation` from ``ValueObservation`` or
|
||||
/// ``SharedValueObservation``.
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public struct AsyncValueObservation<Element>: AsyncSequence {
|
||||
public typealias BufferingPolicy = AsyncThrowingStream<Element, Error>.Continuation.BufferingPolicy
|
||||
public typealias AsyncIterator = Iterator
|
||||
|
||||
var bufferingPolicy: BufferingPolicy
|
||||
var start: ValueObservationStart<Element>
|
||||
|
||||
public func makeAsyncIterator() -> Iterator {
|
||||
// This cancellable will be retained by the Iterator, which itself will
|
||||
// be retained by the Swift async runtime.
|
||||
//
|
||||
// We must not retain this cancellable in any other way, in order to
|
||||
// cancel the observation when the Swift async runtime releases
|
||||
// the iterator.
|
||||
var cancellable: AnyDatabaseCancellable?
|
||||
let stream = AsyncThrowingStream(Element.self, bufferingPolicy: bufferingPolicy) { continuation in
|
||||
cancellable = start(
|
||||
// onError
|
||||
{ error in
|
||||
continuation.finish(throwing: error)
|
||||
},
|
||||
// onChange
|
||||
{ [weak cancellable] element in
|
||||
if case .terminated = continuation.yield(element) {
|
||||
// TODO: I could never see this code running. Is it needed?
|
||||
cancellable?.cancel()
|
||||
}
|
||||
})
|
||||
continuation.onTermination = { @Sendable [weak cancellable] _ in
|
||||
cancellable?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
let iterator = stream.makeAsyncIterator()
|
||||
if let cancellable {
|
||||
return Iterator(
|
||||
iterator: iterator,
|
||||
cancellable: cancellable)
|
||||
} else {
|
||||
// GRDB bug: there is no point throwing any error.
|
||||
fatalError("Expected AsyncThrowingStream to have started the observation already")
|
||||
}
|
||||
}
|
||||
|
||||
public struct Iterator: AsyncIteratorProtocol {
|
||||
var iterator: AsyncThrowingStream<Element, Error>.AsyncIterator
|
||||
let cancellable: AnyDatabaseCancellable
|
||||
|
||||
public mutating func next() async throws -> Element? {
|
||||
try await iterator.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Combine)
|
||||
extension ValueObservation {
|
||||
// MARK: - Publishing Observed Values
|
||||
|
||||
/// Returns a publisher of observed values.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let publisher = observation.publisher(in: dbQueue)
|
||||
///
|
||||
/// let cancellable = publisher.sink { completion in
|
||||
/// // handle completion
|
||||
/// } receiveValue: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// By default, fresh values are dispatched asynchronously on the
|
||||
/// main dispatch queue. You can change this behavior by providing a
|
||||
/// scheduler.
|
||||
///
|
||||
/// For example, the ``ValueObservationScheduler/immediate`` scheduler
|
||||
/// notifies all values on the main dispatch queue, and notifies the first
|
||||
/// one immediately when the observation starts. The `immediate` scheduling
|
||||
/// requires that the observation starts from the main dispatch queue (a
|
||||
/// fatal error is raised otherwise):
|
||||
///
|
||||
/// ```swift
|
||||
/// let publisher = observation.publisher(in: dbQueue, scheduling: .immediate)
|
||||
///
|
||||
/// let cancellable = publisher.sink { completion in
|
||||
/// // handle completion
|
||||
/// } receiveValue: { (players: [Player]) in
|
||||
/// print("Fresh players: \(players)")
|
||||
/// }
|
||||
/// // <- here "Fresh players" is already printed.
|
||||
/// ```
|
||||
///
|
||||
/// - parameter reader: A DatabaseReader.
|
||||
/// - parameter scheduler: A ValueObservationScheduler. By default, fresh
|
||||
/// values are dispatched asynchronously on the main dispatch queue.
|
||||
/// - returns: A Combine publisher
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func publisher(
|
||||
in reader: some DatabaseReader,
|
||||
scheduling scheduler: some ValueObservationScheduler = .async(onQueue: .main))
|
||||
-> DatabasePublishers.Value<Reducer.Value>
|
||||
where Reducer: ValueReducer
|
||||
{
|
||||
DatabasePublishers.Value { (onError, onChange) in
|
||||
self.start(
|
||||
in: reader,
|
||||
scheduling: scheduler,
|
||||
onError: onError,
|
||||
onChange: onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
extension DatabasePublishers {
|
||||
/// A publisher that publishes the values of a ``ValueObservation``.
|
||||
///
|
||||
/// You build such a publisher from ``ValueObservation``
|
||||
/// or ``SharedValueObservation``.
|
||||
public struct Value<Output>: Publisher {
|
||||
public typealias Failure = Error
|
||||
private let start: ValueObservationStart<Output>
|
||||
|
||||
init(start: @escaping ValueObservationStart<Output>) {
|
||||
self.start = start
|
||||
}
|
||||
|
||||
public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
|
||||
let subscription = ValueSubscription(
|
||||
start: start,
|
||||
downstream: subscriber)
|
||||
subscriber.receive(subscription: subscription)
|
||||
}
|
||||
}
|
||||
|
||||
private class ValueSubscription<Downstream: Subscriber>: Subscription
|
||||
where Downstream.Failure == Error
|
||||
{
|
||||
private struct WaitingForDemand {
|
||||
let downstream: Downstream
|
||||
let start: ValueObservationStart<Downstream.Input>
|
||||
}
|
||||
|
||||
private struct Observing {
|
||||
let downstream: Downstream
|
||||
var remainingDemand: Subscribers.Demand
|
||||
}
|
||||
|
||||
private enum State {
|
||||
/// Waiting for demand, not observing the database.
|
||||
case waitingForDemand(WaitingForDemand)
|
||||
|
||||
/// Observing the database. Self.observer is not nil.
|
||||
case observing(Observing)
|
||||
|
||||
/// Completed or cancelled, not observing the database.
|
||||
case finished
|
||||
}
|
||||
|
||||
// Cancellable is not stored in self.state because we must enter the
|
||||
// .observing state *before* the observation starts, so that the user
|
||||
// can change the state even before the cancellable is known.
|
||||
private var cancellable: AnyDatabaseCancellable?
|
||||
private var state: State
|
||||
private var lock = NSRecursiveLock() // Allow re-entrancy
|
||||
|
||||
init(
|
||||
start: @escaping ValueObservationStart<Downstream.Input>,
|
||||
downstream: Downstream)
|
||||
{
|
||||
state = .waitingForDemand(WaitingForDemand(
|
||||
downstream: downstream,
|
||||
start: start))
|
||||
}
|
||||
|
||||
func request(_ demand: Subscribers.Demand) {
|
||||
lock.synchronized {
|
||||
switch state {
|
||||
case let .waitingForDemand(info):
|
||||
guard demand > 0 else {
|
||||
return
|
||||
}
|
||||
state = .observing(Observing(
|
||||
downstream: info.downstream,
|
||||
remainingDemand: demand))
|
||||
let cancellable = info.start(
|
||||
{ [weak self] error in self?.receiveCompletion(.failure(error)) },
|
||||
{ [weak self] value in self?.receive(value) })
|
||||
|
||||
// State may have been altered (error or cancellation)
|
||||
switch state {
|
||||
case .waitingForDemand:
|
||||
preconditionFailure()
|
||||
case .observing:
|
||||
self.cancellable = cancellable
|
||||
case .finished:
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
case var .observing(info):
|
||||
info.remainingDemand += demand
|
||||
state = .observing(info)
|
||||
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.synchronized { sideEffect in
|
||||
let cancellable = self.cancellable
|
||||
self.cancellable = nil
|
||||
self.state = .finished
|
||||
sideEffect = {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(_ value: Downstream.Input) {
|
||||
lock.synchronized {
|
||||
if case let .observing(info) = state,
|
||||
info.remainingDemand > .none
|
||||
{
|
||||
let additionalDemand = info.downstream.receive(value)
|
||||
if case var .observing(info) = state {
|
||||
info.remainingDemand += additionalDemand
|
||||
info.remainingDemand -= 1
|
||||
state = .observing(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveCompletion(_ completion: Subscribers.Completion<Error>) {
|
||||
lock.synchronized { sideEffect in
|
||||
if case let .observing(info) = state {
|
||||
cancellable = nil
|
||||
state = .finished
|
||||
sideEffect = {
|
||||
info.downstream.receive(completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension ValueObservation {
|
||||
|
||||
// MARK: - Creating ValueObservation
|
||||
|
||||
/// Creates an optimized `ValueObservation` that notifies the fetched value
|
||||
/// whenever it changes.
|
||||
///
|
||||
/// Unlike observations created with ``tracking(_:)``, the returned
|
||||
/// observation can reduce database contention, by not blocking
|
||||
/// database writes when fresh values are fetched. It can also avoid
|
||||
/// fetching fresh values from the main thread, after the database was
|
||||
/// modified on the main thread.
|
||||
///
|
||||
/// Those scheduling optimizations are only applied when the observation
|
||||
/// is started from a ``DatabasePool``. You can start such an
|
||||
/// observation from a ``DatabaseQueue``, but the optimizations will not
|
||||
/// be applied. The notified values will be the same, though. This makes
|
||||
/// it possible to use a pool in the main application, and an in-memory
|
||||
/// queue in tests and Xcode previews.
|
||||
///
|
||||
/// **Precondition**: The `fetch` function must perform requests that fetch
|
||||
/// from a single and constant database region. This region is made of
|
||||
/// tables, columns, and rowids of individual rows. All changes that happen
|
||||
/// outside of this region are not notified.
|
||||
///
|
||||
/// For example, the observations below track a constant region and can
|
||||
/// be optimized:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Tracks the full 'player' table
|
||||
/// let observation = ValueObservation.trackingConstantRegion { db -> [Player] in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// // Tracks the row with id 42 in the 'player' table
|
||||
/// let observation = ValueObservation.trackingConstantRegion { db -> Player? in
|
||||
/// try Player.fetchOne(db, key: 42)
|
||||
/// }
|
||||
///
|
||||
/// // Tracks the 'score' column in the 'player' table
|
||||
/// let observation = ValueObservation.trackingConstantRegion { db -> Int? in
|
||||
/// try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player")
|
||||
/// }
|
||||
///
|
||||
/// // Tracks both the 'player' and 'team' tables
|
||||
/// let observation = ValueObservation.trackingConstantRegion { db -> ([Team], [Player]) in
|
||||
/// let teams = try Team.fetchAll(db)
|
||||
/// let players = try Player.fetchAll(db)
|
||||
/// return (teams, players)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// **Observations that do not track a constant database region must not
|
||||
/// use this method, because some changes may not be notified to
|
||||
/// the application.**
|
||||
///
|
||||
/// For example, the observations below do not track a constant region.
|
||||
/// They are correctly defined with ``tracking(_:)``, since
|
||||
/// `trackingConstantRegion(_:)` is unsuited:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Does not always track the same row in the 'player' table:
|
||||
/// let observation = ValueObservation.tracking { db -> Player in
|
||||
/// let config = try AppConfiguration.find(db)
|
||||
/// let playerId: Int64 = config.favoritePlayerId
|
||||
/// return try Player.find(db, id: playerId)
|
||||
/// }
|
||||
///
|
||||
/// // Does not always track the 'player' table, or not always the same
|
||||
/// // rows in the 'player' table:
|
||||
/// let observation = ValueObservation.tracking { db -> [Player] in
|
||||
/// let config = try AppConfiguration.find(db)
|
||||
/// let playerIds: [Int64] = config.favoritePlayerIds
|
||||
/// // Not only playerIds can change, but when it is empty,
|
||||
/// // the player table is not tracked at all.
|
||||
/// return try Player.fetchAll(db, ids: playerIds)
|
||||
/// }
|
||||
///
|
||||
/// // Sometimes tracks the 'food' table, and sometimes the 'beverage' table.
|
||||
/// let observation = ValueObservation.tracking { db -> Int in
|
||||
/// let config = try AppConfiguration.find(db)
|
||||
/// switch config.selection {
|
||||
/// case .food:
|
||||
/// return try Food.fetchCount(db)
|
||||
/// case .beverage:
|
||||
/// return try Beverage.fetchCount(db)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Since only observations of a constant region can achieve important
|
||||
/// scheduling optimizations (such as the guarantee that fresh values
|
||||
/// are never fetched from the main thread –
|
||||
/// see <doc:ValueObservation#ValueObservation-Scheduling>), you can
|
||||
/// always create one:
|
||||
///
|
||||
/// - With ``tracking(regions:fetch:)``, you provide all tracked
|
||||
/// region(s) when the observation is created:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Optimized observation that explicitly tracks the
|
||||
/// // 'appConfiguration', 'food', and 'beverage' tables:
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [
|
||||
/// AppConfiguration.all(),
|
||||
/// Food.all(),
|
||||
/// Beverage.all(),
|
||||
/// ],
|
||||
/// fetch: { db -> Int in
|
||||
/// let config = try AppConfiguration.find(db)
|
||||
/// switch config.selection {
|
||||
/// case .food:
|
||||
/// return try Food.fetchCount(db)
|
||||
/// case .beverage:
|
||||
/// return try Beverage.fetchCount(db)
|
||||
/// }
|
||||
/// })
|
||||
/// ```
|
||||
///
|
||||
/// - With ``Database/registerAccess(to:)``, you extend the list of
|
||||
/// tracked region(s) from the fetching closure:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Optimized observation that implicitly tracks the
|
||||
/// // 'appConfiguration' table, and explicitly tracks 'food'
|
||||
/// // and 'beverage':
|
||||
/// let observation = ValueObservation.trackingConstantRegion { db -> Int in
|
||||
/// try db.registerAccess(to: Food.all())
|
||||
/// try db.registerAccess(to: Beverage.all())
|
||||
///
|
||||
/// let config = try AppConfiguration.find(db)
|
||||
/// switch config.selection {
|
||||
/// case .food:
|
||||
/// return try Food.fetchCount(db)
|
||||
/// case .beverage:
|
||||
/// return try Beverage.fetchCount(db)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter fetch: The closure that fetches the observed value.
|
||||
public static func trackingConstantRegion<Value>(
|
||||
_ fetch: @escaping (Database) throws -> Value)
|
||||
-> Self
|
||||
where Reducer == ValueReducers.Fetch<Value>
|
||||
{
|
||||
.init(
|
||||
trackingMode: .constantRegionRecordedFromSelection,
|
||||
makeReducer: { ValueReducers.Fetch(fetch: fetch) })
|
||||
}
|
||||
|
||||
/// Creates a `ValueObservation` that notifies the fetched value whenever
|
||||
/// the provided regions are modified.
|
||||
///
|
||||
/// Only database transactions that impact the provided regions trigger the
|
||||
/// notification of fresh values.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Tracks the full database
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: .fullDatabase,
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the full 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: Player.all(),
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the full 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: Table("player"),
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the row with id 42 in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: Player.filter(id: 42),
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the 'score' column in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: Player.select(Column("score"),
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the 'score' column in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: SQLRequest("SELECT score FROM player"),
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks both the 'player' and 'team' tables
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// region: Player.all(), Team.all(),
|
||||
/// fetch: { db in ... })
|
||||
/// ```
|
||||
///
|
||||
/// Unlike observations created with ``tracking(_:)``, the returned
|
||||
/// observation can reduce database contention, by not blocking
|
||||
/// database writes when fresh values are fetched. It can also avoid
|
||||
/// fetching fresh values from the main thread, after the database was
|
||||
/// modified on the main thread.
|
||||
///
|
||||
/// Those scheduling optimizations are only applied when the observation
|
||||
/// is started from a ``DatabasePool``. You can start such an
|
||||
/// observation from a ``DatabaseQueue``, but the optimizations will not
|
||||
/// be applied. The notified values will be the same, though. This makes
|
||||
/// it possible to use a pool in the main application, and an in-memory
|
||||
/// queue in tests and Xcode previews.
|
||||
///
|
||||
/// - parameter region: A region to observe.
|
||||
/// - parameter otherRegions: A list of supplementary regions
|
||||
/// to observe.
|
||||
/// - parameter fetch: The closure that fetches the observed value.
|
||||
public static func tracking<Value>(
|
||||
region: any DatabaseRegionConvertible,
|
||||
_ otherRegions: any DatabaseRegionConvertible...,
|
||||
fetch: @escaping (Database) throws -> Value)
|
||||
-> Self
|
||||
where Reducer == ValueReducers.Fetch<Value>
|
||||
{
|
||||
tracking(regions: [region] + otherRegions, fetch: fetch)
|
||||
}
|
||||
|
||||
/// Creates a `ValueObservation` that notifies the fetched value whenever
|
||||
/// the provided regions are modified.
|
||||
///
|
||||
/// Only database transactions that impact the provided regions trigger the
|
||||
/// notification of fresh values.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Tracks the full database
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [.fullDatabase],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the full 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [Player.all()],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the full 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [Table("player")],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the row with id 42 in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [Player.filter(id: 42)],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the 'score' column in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [Player.select(Column("score")],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks the 'score' column in the 'player' table
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [SQLRequest("SELECT score FROM player")],
|
||||
/// fetch: { db in ... })
|
||||
///
|
||||
/// // Tracks both the 'player' and 'team' tables
|
||||
/// let observation = ValueObservation.tracking(
|
||||
/// regions: [Player.all(), Team.all()],
|
||||
/// fetch: { db in ... })
|
||||
/// ```
|
||||
///
|
||||
/// Unlike observations created with ``tracking(_:)``, the returned
|
||||
/// observation can reduce database contention, by not blocking
|
||||
/// database writes when fresh values are fetched. It can also avoid
|
||||
/// fetching fresh values from the main thread, after the database was
|
||||
/// modified on the main thread.
|
||||
///
|
||||
/// Those scheduling optimizations are only applied when the observation
|
||||
/// is started from a ``DatabasePool``. You can start such an
|
||||
/// observation from a ``DatabaseQueue``, but the optimizations will not
|
||||
/// be applied. The notified values will be the same, though. This makes
|
||||
/// it possible to use a pool in the main application, and an in-memory
|
||||
/// queue in tests and Xcode previews.
|
||||
///
|
||||
/// - parameter regions: An array of observed regions.
|
||||
/// - parameter fetch: The closure that fetches the observed value.
|
||||
public static func tracking<Value>(
|
||||
regions: [any DatabaseRegionConvertible],
|
||||
fetch: @escaping (Database) throws -> Value)
|
||||
-> Self
|
||||
where Reducer == ValueReducers.Fetch<Value>
|
||||
{
|
||||
.init(
|
||||
trackingMode: .constantRegion(regions),
|
||||
makeReducer: { ValueReducers.Fetch(fetch: fetch) })
|
||||
}
|
||||
|
||||
/// Creates a `ValueObservation` that notifies the fetched values whenever
|
||||
/// it changes.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let cancellable = try observation.start(in: dbQueue) { error in
|
||||
/// // handle error
|
||||
/// } onChange: { (players: [Player]) in
|
||||
/// print("Players have changed")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// An observation can perform multiple requests, from multiple database
|
||||
/// tables, and even use raw SQL:
|
||||
///
|
||||
/// ```swift
|
||||
/// struct HallOfFame {
|
||||
/// var totalPlayerCount: Int
|
||||
/// var bestPlayers: [Player]
|
||||
/// }
|
||||
///
|
||||
/// // An observation of HallOfFame
|
||||
/// let observation = ValueObservation.tracking { db -> HallOfFame in
|
||||
/// let totalPlayerCount = try Player.fetchCount(db)
|
||||
///
|
||||
/// let bestPlayers = try Player
|
||||
/// .order(Column("score").desc)
|
||||
/// .limit(10)
|
||||
/// .fetchAll(db)
|
||||
///
|
||||
/// return HallOfFame(
|
||||
/// totalPlayerCount: totalPlayerCount,
|
||||
/// bestPlayers: bestPlayers)
|
||||
/// }
|
||||
///
|
||||
/// // An observation of the maximum score
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - parameter fetch: The closure that fetches the observed value.
|
||||
public static func tracking<Value>(
|
||||
_ fetch: @escaping (Database) throws -> Value)
|
||||
-> Self
|
||||
where Reducer == ValueReducers.Fetch<Value>
|
||||
{
|
||||
.init(
|
||||
trackingMode: .nonConstantRegionRecordedFromSelection,
|
||||
makeReducer: { ValueReducers.Fetch(fetch: fetch) })
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
/// A type that determines when `ValueObservation` notifies its fresh values.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Built-In Schedulers
|
||||
///
|
||||
/// - ``async(onQueue:)``
|
||||
/// - ``immediate``
|
||||
/// - ``AsyncValueObservationScheduler``
|
||||
/// - ``ImmediateValueObservationScheduler``
|
||||
public protocol ValueObservationScheduler {
|
||||
/// Returns whether the initial value should be immediately notified.
|
||||
///
|
||||
/// If the result is true, then this method was called on the main thread.
|
||||
func immediateInitialValue() -> Bool
|
||||
|
||||
func schedule(_ action: @escaping () -> Void)
|
||||
}
|
||||
|
||||
extension ValueObservationScheduler {
|
||||
func scheduleInitial(_ action: @escaping () -> Void) {
|
||||
if immediateInitialValue() {
|
||||
action()
|
||||
} else {
|
||||
schedule(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AsyncValueObservationScheduler
|
||||
|
||||
/// A scheduler that asynchronously notifies fresh value of a `DispatchQueue`.
|
||||
public struct AsyncValueObservationScheduler: ValueObservationScheduler {
|
||||
var queue: DispatchQueue
|
||||
|
||||
public init(queue: DispatchQueue) {
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
public func immediateInitialValue() -> Bool { false }
|
||||
|
||||
public func schedule(_ action: @escaping () -> Void) {
|
||||
queue.async(execute: action)
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueObservationScheduler where Self == AsyncValueObservationScheduler {
|
||||
/// A scheduler that asynchronously notifies fresh value of the
|
||||
/// given `DispatchQueue`.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let cancellable = try observation.start(
|
||||
/// in: dbQueue,
|
||||
/// scheduling: .async(onQueue: .main),
|
||||
/// onError: { error in ... },
|
||||
/// onChange: { (players: [Player]) in
|
||||
/// print("fresh players: \(players)")
|
||||
/// })
|
||||
/// ```
|
||||
///
|
||||
/// - warning: Make sure you provide a serial queue, because a
|
||||
/// concurrent one such as `DispachQueue.global(qos: .default)` would
|
||||
/// mess with the ordering of fresh value notifications.
|
||||
public static func async(onQueue queue: DispatchQueue) -> AsyncValueObservationScheduler {
|
||||
AsyncValueObservationScheduler(queue: queue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImmediateValueObservationScheduler
|
||||
|
||||
/// A scheduler that notifies all values on the main `DispatchQueue`. The
|
||||
/// first value is immediately notified when the `ValueObservation`
|
||||
/// is started.
|
||||
public struct ImmediateValueObservationScheduler: ValueObservationScheduler, Sendable {
|
||||
public init() { }
|
||||
|
||||
public func immediateInitialValue() -> Bool {
|
||||
GRDBPrecondition(
|
||||
Thread.isMainThread,
|
||||
"ValueObservation must be started from the main thread.")
|
||||
return true
|
||||
}
|
||||
|
||||
public func schedule(_ action: @escaping () -> Void) {
|
||||
DispatchQueue.main.async(execute: action)
|
||||
}
|
||||
}
|
||||
|
||||
extension ValueObservationScheduler where Self == ImmediateValueObservationScheduler {
|
||||
/// A scheduler that notifies all values on the main `DispatchQueue`. The
|
||||
/// first value is immediately notified when the `ValueObservation`
|
||||
/// is started.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let observation = ValueObservation.tracking { db in
|
||||
/// try Player.fetchAll(db)
|
||||
/// }
|
||||
///
|
||||
/// let cancellable = try observation.start(
|
||||
/// in: dbQueue,
|
||||
/// scheduling: .immediate,
|
||||
/// onError: { error in ... },
|
||||
/// onChange: { (players: [Player]) in
|
||||
/// print("fresh players: \(players)")
|
||||
/// })
|
||||
/// // <- here "fresh players" is already printed.
|
||||
/// ```
|
||||
///
|
||||
/// - important: this scheduler requires that the observation is started
|
||||
/// from the main queue. A fatal error is raised otherwise.
|
||||
public static var immediate: ImmediateValueObservationScheduler {
|
||||
ImmediateValueObservationScheduler()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user