add swiftUI code

This commit is contained in:
zeus
2025-01-22 14:09:10 +08:00
parent 68e7b7347c
commit 8a99853829
2531 changed files with 486215 additions and 0 deletions
@@ -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
}
}
@@ -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(&region) {
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
}
}
}
@@ -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(&region) {
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)
}
}
@@ -0,0 +1,98 @@
extension ValueObservation {
/// Notifies only values that dont 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 dont 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 dont 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 { }
@@ -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) })
}
}
@@ -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()
}
}