diff --git a/Sources/Blackbird/BlackbirdSwiftUI.swift b/Sources/Blackbird/BlackbirdSwiftUI.swift index 5bb5bbb..1bc0f92 100644 --- a/Sources/Blackbird/BlackbirdSwiftUI.swift +++ b/Sources/Blackbird/BlackbirdSwiftUI.swift @@ -493,3 +493,156 @@ extension Blackbird { } } } + +// MARK: - Single-column updater + +/// SwiftUI property wrapper for automatic updating of a single column value for the specified primary-key value. +/// +/// ## Example +/// +/// Given a model defined with this column: +/// ```swift +/// struct MyModel: BlackbirdModel { +/// // ... +/// @BlackbirdColumn var title: String +/// // ... +/// } +/// ``` +/// +/// Then, in a SwiftUI view: +/// +/// ```swift +/// struct MyView: View { +/// // title will be of type: String? +/// @BlackbirdColumnObserver(\MyModel.$title, primaryKey: 123) var title +/// +/// var body: some View { +/// Text(title ?? "Loading…") +/// } +/// ``` +/// +/// Or, to provide the primary-key value dynamically: +/// +/// ```swift +/// struct MyView: View { +/// // title will be of type: String? +/// @BlackbirdColumnObserver(\MyModel.$title) var title +/// +/// init(primaryKey: Any) { +/// _title = BlackbirdColumnObserver(\MyModel.$title, primaryKey: primaryKey) +/// } +/// +/// var body: some View { +/// Text(title ?? "Loading…") +/// } +/// ``` +@propertyWrapper public struct BlackbirdColumnObserver: DynamicProperty { + @Environment(\.blackbirdDatabase) var environmentDatabase + + @State private var primaryKey: Any? + @State private var currentValue: V? = nil + + public var wrappedValue: V? { + get { currentValue } + nonmutating set { fatalError() } + } + + public var projectedValue: Binding { $currentValue } + + private var observer: BlackbirdColumnObserverInternal? + + public init(_ column: KeyPath>, primaryKey: Any? = nil) { + self.observer = BlackbirdColumnObserverInternal(modelType: T.self, column: column) + _primaryKey = State(initialValue: primaryKey) + } + + public mutating func update() { + observer?.bind(to: environmentDatabase, primaryKey: $primaryKey, result: $currentValue) + } +} + +internal final class BlackbirdColumnObserverInternal: @unchecked Sendable /* unchecked due to internal locking */ { + @Binding private var primaryKey: Any? + @Binding private var result: V? + + private let configLock = Blackbird.Lock() + private var column: T.BlackbirdColumnKeyPath + private var database: Blackbird.Database? = nil + private var listeners: [AnyCancellable] = [] + private var lastPrimaryKeyValue: Blackbird.Value? = nil + + public init(modelType: T.Type, column: T.BlackbirdColumnKeyPath) { + self.column = column + _primaryKey = Binding(get: { nil }, set: { _ in }) + _result = Binding(get: { nil }, set: { _ in }) + } + + public func bind(to database: Blackbird.Database? = nil, primaryKey: Binding, result: Binding) { + _result = result + _primaryKey = primaryKey + + let needsUpdate = configLock.withLock { + let newPK = primaryKey.wrappedValue != nil ? try! Blackbird.Value.fromAny(primaryKey.wrappedValue) : nil + if let existingDB = self.database, let newDB = database, existingDB == newDB, lastPrimaryKeyValue == newPK { return false } + + listeners.removeAll() + self.database = database + self.lastPrimaryKeyValue = newPK + if let database, let primaryKey = newPK { + T.changePublisher(in: database, primaryKey: primaryKey, columns: [column]) + .sink { [weak self] _ in self?.update() } + .store(in: &listeners) + } + + return true + } + + if needsUpdate { + update() + } + } + + private func setResult(_ value: V?) { + Task { + await MainActor.run { + if value != result { + result = value + } + } + } + } + + private func update() { + guard let database else { + setResult(nil) + return + } + + Task.detached { [weak self] in + do { + try await self?.fetch(in: database) + } catch { + self?.setResult(nil) + } + } + } + + private func fetch(in database: Blackbird.Database) async throws { + configLock.lock() + let primaryKey = primaryKey + let column = column + let database = database + configLock.unlock() + + guard let primaryKey else { return } + + guard let row = try await T.query(in: database, columns: [column], primaryKey: primaryKey) else { + setResult(nil) + return + } + + let newValue = V.fromValue(row.value(keyPath: column)!)! + setResult(newValue) + } +} +