add swiftUI code
This commit is contained in:
+488
@@ -0,0 +1,488 @@
|
||||
import Foundation
|
||||
|
||||
/// `ValueWriteOnlyObserver` observes the database for `ValueObservation`.
|
||||
///
|
||||
/// It performs the following database observation cycle:
|
||||
///
|
||||
/// 1. Start observation or detect a database change
|
||||
/// 2. Fetch
|
||||
/// 3. Reduce
|
||||
/// 4. Notify
|
||||
///
|
||||
/// **Fetch** is always performed from the writer database connection (hence
|
||||
/// the name of this observer).
|
||||
///
|
||||
/// **Reduce** is the operation that turns the fetched database values into the
|
||||
/// observed values. Those are not the same. Consider, for example, the `map()`
|
||||
/// and `removeDuplicates()` operators: they perform their job during the
|
||||
/// reducing stage.
|
||||
///
|
||||
/// **Notify** is calling user callbacks, in case of database change or error.
|
||||
final class ValueWriteOnlyObserver<
|
||||
Writer: DatabaseWriter,
|
||||
Reducer: ValueReducer,
|
||||
Scheduler: ValueObservationScheduler>
|
||||
{
|
||||
// MARK: - Configuration
|
||||
//
|
||||
// Configuration is not mutable.
|
||||
|
||||
/// How to schedule observed values and errors.
|
||||
private let scheduler: Scheduler
|
||||
|
||||
/// Configures the tracked database region.
|
||||
private let trackingMode: ValueObservationTrackingMode
|
||||
|
||||
// MARK: - Mutable State
|
||||
//
|
||||
// The observer has four distinct mutable states that evolve independently,
|
||||
// and are made thread-safe with various mechanisms:
|
||||
//
|
||||
// - A `DatabaseAccess`: ability to access the database. It is constant but
|
||||
// turns nil after the observation fails or is cancelled, in order to
|
||||
// release memory and resources when the observation completes. It is
|
||||
// guarded by `lock`, because observation can fail or be cancelled from
|
||||
// multiple threads.
|
||||
//
|
||||
// - A `NotificationCallbacks`: ability to notify observation events. It is
|
||||
// constant but turns nil when failure or cancellation is notified, in
|
||||
// order to release memory and resources when the observation completes.
|
||||
// It is guarded by `lock`, because observation can fail or be cancelled
|
||||
// from multiple threads.
|
||||
//
|
||||
// - An `ObservationState`: relationship with the `TransactionObserver`
|
||||
// protocol. It is only accessed from the serialized writer
|
||||
// dispatch queue.
|
||||
//
|
||||
// - A `Reducer`: the observation reducer, only accessed from the
|
||||
// serialized dispatch queue `reduceQueue`.
|
||||
//
|
||||
// The `reduceQueue` guarantees that fresh value notifications have the same
|
||||
// order as transactions. It is different from the serialized writer
|
||||
// dispatch queue because we do not want to lock the database as
|
||||
// computations (`map`, `removeDuplicates()`, etc.) are performed.
|
||||
//
|
||||
// Despite being protected by the same lock, `DatabaseAccess` and
|
||||
// `NotificationCallbacks` are not merged together. This is because the
|
||||
// observer does not lose `DatabaseAccess` at the same time it
|
||||
// looses `NotificationCallbacks`:
|
||||
//
|
||||
// - In case of cancellation, `NotificationCallbacks` is lost first, and
|
||||
// `DatabaseAccess` is lost asynchronously, after the observer could
|
||||
// resign as a transaction observer. See `cancel()`.
|
||||
//
|
||||
// - In case of error, `DatabaseAccess` is lost first, and
|
||||
// `NotificationCallbacks` is lost asynchronously, after the error could
|
||||
// be notified. See error catching clauses.
|
||||
|
||||
/// Ability to access the database
|
||||
private struct DatabaseAccess {
|
||||
/// The observed DatabaseWriter.
|
||||
let writer: Writer
|
||||
|
||||
/// If true, database values are fetched from a read-only access.
|
||||
private let readOnly: Bool
|
||||
|
||||
/// A reducer that fetches database values.
|
||||
private let reducer: Reducer
|
||||
|
||||
init(writer: Writer, readOnly: Bool, reducer: Reducer) {
|
||||
self.writer = writer
|
||||
self.readOnly = readOnly
|
||||
self.reducer = reducer
|
||||
}
|
||||
|
||||
func fetch(_ db: Database) throws -> Reducer.Fetched {
|
||||
try db.isolated(readOnly: readOnly) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRecordingObservedRegion(_ db: Database) throws -> (Reducer.Fetched, DatabaseRegion) {
|
||||
var region = DatabaseRegion()
|
||||
let fetchedValue = try db.isolated(readOnly: readOnly) {
|
||||
try db.recordingSelection(®ion) {
|
||||
try reducer._fetch(db)
|
||||
}
|
||||
}
|
||||
return try (fetchedValue, region.observableRegion(db))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ability to notify observation events
|
||||
private struct NotificationCallbacks {
|
||||
let events: ValueObservationEvents
|
||||
let onChange: (Reducer.Value) -> Void
|
||||
}
|
||||
|
||||
/// Relationship with the `TransactionObserver` protocol
|
||||
private struct ObservationState {
|
||||
var region: DatabaseRegion?
|
||||
var isModified = false
|
||||
|
||||
static var notObserving: Self { .init(region: nil, isModified: false) }
|
||||
}
|
||||
|
||||
/// Protects `databaseAccess` and `notificationCallbacks`.
|
||||
private let lock = NSLock()
|
||||
|
||||
/// The dispatch queue where database values are reduced into observed
|
||||
/// values before being notified. Protects `reducer`.
|
||||
private let reduceQueue: DispatchQueue
|
||||
|
||||
/// Access to the database, protected by `lock`.
|
||||
private var databaseAccess: DatabaseAccess?
|
||||
|
||||
/// Ability to notify observation events, protected by `lock`.
|
||||
private var notificationCallbacks: NotificationCallbacks?
|
||||
|
||||
/// Support for `TransactionObserver`, protected by the serialized writer
|
||||
/// dispatch queue.
|
||||
private var observationState = ObservationState.notObserving
|
||||
|
||||
/// Protected by `reduceQueue`.
|
||||
private var reducer: Reducer
|
||||
|
||||
init(
|
||||
writer: Writer,
|
||||
scheduler: Scheduler,
|
||||
readOnly: Bool,
|
||||
trackingMode: ValueObservationTrackingMode,
|
||||
reducer: Reducer,
|
||||
events: ValueObservationEvents,
|
||||
onChange: @escaping (Reducer.Value) -> Void)
|
||||
{
|
||||
// Configuration
|
||||
self.scheduler = scheduler
|
||||
self.trackingMode = trackingMode
|
||||
|
||||
// State
|
||||
self.databaseAccess = DatabaseAccess(
|
||||
writer: writer,
|
||||
readOnly: readOnly,
|
||||
// ValueReducer semantics guarantees that reducer._fetch
|
||||
// is independent from the reducer state
|
||||
reducer: reducer)
|
||||
self.notificationCallbacks = NotificationCallbacks(events: events, onChange: onChange)
|
||||
self.reducer = reducer
|
||||
self.reduceQueue = DispatchQueue(
|
||||
label: writer.configuration.identifier(
|
||||
defaultLabel: "GRDB",
|
||||
purpose: "ValueObservation"),
|
||||
qos: writer.configuration.readQoS)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Starting the Observation
|
||||
|
||||
extension ValueWriteOnlyObserver {
|
||||
// Starts the observation
|
||||
func start() -> AnyDatabaseCancellable {
|
||||
let (notificationCallbacks, writer) = lock.synchronized {
|
||||
(self.notificationCallbacks, self.databaseAccess?.writer)
|
||||
}
|
||||
guard let notificationCallbacks, let writer else {
|
||||
// Likely a GRDB bug: during a synchronous start, user is not
|
||||
// able to cancel observation.
|
||||
fatalError("can't start a cancelled or failed observation")
|
||||
}
|
||||
|
||||
if scheduler.immediateInitialValue() {
|
||||
do {
|
||||
// Start the observation in an synchronous way
|
||||
let initialValue = try syncStart(from: writer)
|
||||
|
||||
// Notify the initial value from the dispatch queue the
|
||||
// observation was started from
|
||||
notificationCallbacks.onChange(initialValue)
|
||||
} catch {
|
||||
// Notify error from the dispatch queue the observation
|
||||
// was started from.
|
||||
notificationCallbacks.events.didFail?(error)
|
||||
|
||||
// Early return!
|
||||
return AnyDatabaseCancellable { /* nothing to cancel */ }
|
||||
}
|
||||
} else {
|
||||
// Start the observation in an asynchronous way
|
||||
asyncStart(from: writer)
|
||||
}
|
||||
|
||||
// Make sure the returned cancellable cancels the observation
|
||||
// when deallocated. We can't relying on the deallocation of
|
||||
// self to trigger early cancellation, because self may be retained by
|
||||
// some closure waiting to run in some DispatchQueue.
|
||||
return AnyDatabaseCancellable(self)
|
||||
}
|
||||
|
||||
/// Synchronously starts the observation, and returns the initial value.
|
||||
///
|
||||
/// Unlike `asyncStart()`, this method does not notify the initial value or error.
|
||||
private func syncStart(from writer: Writer) throws -> Reducer.Value {
|
||||
// Start from a write access, so that self can register as a
|
||||
// transaction observer.
|
||||
//
|
||||
// Start in a synchronous reentrant way, in case this method is called
|
||||
// from a database access.
|
||||
try writer.unsafeReentrantWrite { db in
|
||||
// Fetch & Start observing the database
|
||||
guard let fetchedValue = try fetchAndStartObservation(db) else {
|
||||
// Likely a GRDB bug: during a synchronous start, user is not
|
||||
// able to cancel observation.
|
||||
fatalError("can't start a cancelled or failed observation")
|
||||
}
|
||||
|
||||
// Reduce
|
||||
return try reduceQueue.sync {
|
||||
guard let initialValue = try reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
return initialValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asynchronously starts the observation
|
||||
///
|
||||
/// Unlike `syncStart()`, this method does notify the initial value or error.
|
||||
private func asyncStart(from writer: Writer) {
|
||||
// Start from a write access, so that self can register as a
|
||||
// transaction observer.
|
||||
writer.asyncWriteWithoutTransaction { db in
|
||||
do {
|
||||
// Fetch & Start observing the database
|
||||
guard let fetchedValue = try self.fetchAndStartObservation(db) else {
|
||||
return /* Cancelled */
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering
|
||||
// between transactions and notifications!
|
||||
self.reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
guard let initialValue = try self.reducer._value(fetchedValue) else {
|
||||
fatalError("Broken contract: reducer has no initial value")
|
||||
}
|
||||
|
||||
// Notify
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(initialValue)
|
||||
}
|
||||
} catch {
|
||||
let writer = self.lock.synchronized { self.databaseAccess?.writer }
|
||||
writer?.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.stopDatabaseObservation(db)
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the initial value, and start observing the database.
|
||||
///
|
||||
/// Returns nil if the observation was cancelled before database observation
|
||||
/// could start.
|
||||
///
|
||||
/// By grouping the initial fetch and the beginning of observation in a
|
||||
/// single database access, we are sure that no concurrent write can happen
|
||||
/// during the initial fetch, and that we won't miss any future change.
|
||||
private func fetchAndStartObservation(_ db: Database) throws -> Reducer.Fetched? {
|
||||
let (events, databaseAccess) = lock.synchronized {
|
||||
(notificationCallbacks?.events, self.databaseAccess)
|
||||
}
|
||||
guard let events, let databaseAccess else {
|
||||
return nil /* Cancelled */
|
||||
}
|
||||
|
||||
switch trackingMode {
|
||||
case let .constantRegion(regions):
|
||||
let fetchedValue = try databaseAccess.fetch(db)
|
||||
let region = try DatabaseRegion.union(regions)(db)
|
||||
let observedRegion = try region.observableRegion(db)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
startObservation(db, observedRegion: observedRegion)
|
||||
return fetchedValue
|
||||
|
||||
case .constantRegionRecordedFromSelection,
|
||||
.nonConstantRegionRecordedFromSelection:
|
||||
let (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
events.willTrackRegion?(observedRegion)
|
||||
startObservation(db, observedRegion: observedRegion)
|
||||
return fetchedValue
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservation(_ db: Database, observedRegion: DatabaseRegion) {
|
||||
observationState.region = observedRegion
|
||||
assert(observationState.isModified == false)
|
||||
db.add(transactionObserver: self, extent: .observerLifetime)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observing Database Transactions
|
||||
|
||||
extension ValueWriteOnlyObserver: TransactionObserver {
|
||||
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
|
||||
if let region = observationState.region {
|
||||
return region.isModified(byEventsOfKind: eventKind)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidChange() {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
|
||||
func databaseDidChange(with event: DatabaseEvent) {
|
||||
if let region = observationState.region, region.isModified(by: event) {
|
||||
// Database was modified!
|
||||
observationState.isModified = true
|
||||
// We can stop observing the current transaction
|
||||
stopObservingDatabaseChangesUntilNextTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidCommit(_ db: Database) {
|
||||
// Ignore transaction unless database was modified
|
||||
guard observationState.isModified else { return }
|
||||
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
|
||||
// Ignore transaction unless we are still notifying database events, and
|
||||
// we can still fetch fresh values.
|
||||
let (events, databaseAccess) = lock.synchronized {
|
||||
(notificationCallbacks?.events, self.databaseAccess)
|
||||
}
|
||||
guard let events, let databaseAccess else { return /* Cancelled */ }
|
||||
|
||||
// Notify
|
||||
events.databaseDidChange?()
|
||||
|
||||
do {
|
||||
// Fetch
|
||||
let fetchedValue: Reducer.Fetched
|
||||
|
||||
switch trackingMode {
|
||||
case .constantRegion, .constantRegionRecordedFromSelection:
|
||||
// Tracked region is already known. Fetch only.
|
||||
fetchedValue = try databaseAccess.fetch(db)
|
||||
case .nonConstantRegionRecordedFromSelection:
|
||||
// Fetch and update the tracked region.
|
||||
let (value, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(db)
|
||||
fetchedValue = value
|
||||
|
||||
// Don't spam the user with region tracking events: wait for an actual change
|
||||
if let willTrackRegion = events.willTrackRegion, observedRegion != observationState.region {
|
||||
willTrackRegion(observedRegion)
|
||||
}
|
||||
|
||||
observationState.region = observedRegion
|
||||
}
|
||||
|
||||
// Reduce
|
||||
//
|
||||
// Reducing is performed asynchronously, so that we do not lock
|
||||
// the writer dispatch queue longer than necessary.
|
||||
//
|
||||
// Important: reduceQueue.async guarantees the same ordering between
|
||||
// transactions and notifications!
|
||||
reduceQueue.async {
|
||||
let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil }
|
||||
guard isNotifying else { return /* Cancelled */ }
|
||||
|
||||
do {
|
||||
let value = try self.reducer._value(fetchedValue)
|
||||
|
||||
// Notify value
|
||||
if let value {
|
||||
self.scheduler.schedule {
|
||||
let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange }
|
||||
guard let onChange else { return /* Cancelled */ }
|
||||
onChange(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let writer = self.lock.synchronized { self.databaseAccess?.writer }
|
||||
writer?.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
self.notifyError(error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
stopDatabaseObservation(db)
|
||||
notifyError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func databaseDidRollback(_ db: Database) {
|
||||
// Reset the isModified flag until next transaction
|
||||
observationState.isModified = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ending the Observation
|
||||
|
||||
extension ValueWriteOnlyObserver: DatabaseCancellable {
|
||||
func cancel() {
|
||||
// Notify cancellation
|
||||
let (events, writer) = lock.synchronized {
|
||||
let events = notificationCallbacks?.events
|
||||
// Set callbacks to nil so that we can't notify anything after
|
||||
// the cancellation.
|
||||
notificationCallbacks = nil
|
||||
return (events, databaseAccess?.writer)
|
||||
}
|
||||
|
||||
guard let events else { return /* Cancelled or failed */ }
|
||||
events.didCancel?()
|
||||
|
||||
// Stop observing the database
|
||||
// Do it asynchronously, so that we do not block the current thread:
|
||||
// cancellation may be triggered while a long write access is executing.
|
||||
guard let writer else { return /* Failed */ }
|
||||
writer.asyncWriteWithoutTransaction { db in
|
||||
self.stopDatabaseObservation(db)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyError(_ error: Error) {
|
||||
scheduler.schedule {
|
||||
let events = self.lock.synchronized {
|
||||
let events = self.notificationCallbacks?.events
|
||||
self.notificationCallbacks = nil
|
||||
return events
|
||||
}
|
||||
guard let events else { return /* Cancelled */ }
|
||||
events.didFail?(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDatabaseObservation(_ db: Database) {
|
||||
db.remove(transactionObserver: self)
|
||||
observationState = .notObserving
|
||||
lock.synchronized {
|
||||
databaseAccess = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user