Skip to content

Commit

Permalink
Observable utilities for BlackbirdModel
Browse files Browse the repository at this point in the history
  • Loading branch information
marcoarment committed Dec 5, 2023
1 parent 96ad67b commit df7a20e
Show file tree
Hide file tree
Showing 5 changed files with 414 additions and 2 deletions.
50 changes: 50 additions & 0 deletions Sources/Blackbird/Blackbird.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,54 @@ extension Blackbird {
self.lock.unlock()
}
}


/// Blackbird's async-sempahore utility, offered for public use.
///
/// Suggested use:
///
/// ```swift
/// class MyClass {
/// let myMethodSemaphore = Blackbird.Semaphore(value: 1)
///
/// func myMethod() async {
/// await myMethodSemaphore.wait()
/// defer { myMethodSemaphore.signal() }
///
/// // do async work...
/// }
/// }
/// ```
/// Inspired by [Sebastian Toivonen's approach](https://forums.swift.org/t/semaphore-alternatives-for-structured-concurrency/59353/3).
/// Consider using the Gwendal Roué's more-featured [Semaphore](https://github.com/groue/Semaphore) instead.
public final class Semaphore: Sendable {
private struct State: Sendable {
var value = 0
var waiting: [CheckedContinuation<Void, Never>] = []
}
private let state: Locked<State>

public init(value: Int = 0) { state = Locked(State(value: value)) }

public func wait() async {
let wait = state.withLock { state in
state.value -= 1
return state.value < 0
}

if wait {
await withCheckedContinuation { continuation in
state.withLock { $0.waiting.append(continuation) }
}
}
}

public func signal() {
state.withLock { state in
state.value += 1
if state.waiting.isEmpty { return }
state.waiting.removeFirst().resume()
}
}
}
}
190 changes: 190 additions & 0 deletions Sources/Blackbird/BlackbirdObservation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//
// /\
// | | Blackbird
// | |
// .| |. https://github.com/marcoarment/Blackbird
// $ $
// /$ $\ Copyright 2022–2023 Marco Arment
// / $| |$ \ Released under the MIT License
// .__$| |$__.
// \/
//
// BlackbirdObservation.swift
// Created by Marco Arment on 12/3/23.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//

import Observation
import Combine

// MARK: - BlackbirdModelQueryObserver

@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
extension BlackbirdModel {
public typealias QueryObserver<R> = BlackbirdModelQueryObserver<Self, R>
}

@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
@Observable
public final class BlackbirdModelQueryObserver<T: BlackbirdModel, R: Any> {
/// Whether this instance is currently loading from the database, either initially or after an update.
public var isLoading = false

/// Whether this instance has ever loaded from the database. After the initial load, it is set to `true` and remains true.
public var didLoad = false

/// The current instance matching the supplied primary-key value.
public var result: R?

@ObservationIgnored private weak var database: Blackbird.Database?
@ObservationIgnored private var observer: AnyCancellable? = nil

@ObservationIgnored private var fetcher: ((_ database: Blackbird.Database) async throws -> R)

public init(in database: Blackbird.Database? = nil, _ fetcher: @escaping ((_ database: Blackbird.Database) async throws -> R)) {
self.fetcher = fetcher
bind(to: database)
}

/// Set or change the ``Blackbird/Database`` to read from and monitor for changes.
public func bind(to database: Blackbird.Database?) {
guard let database else { return }
if let oldValue = self.database, oldValue.id == database.id { return }

self.database = database

observer?.cancel()
observer = nil
result = nil

observer = T.changePublisher(in: database).sink { _ in
Task.detached { [weak self] in await self?.update() }
}
Task.detached { [weak self] in await self?.update() }
}

let updateSemaphore = Blackbird.Semaphore(value: 1)
private func update() async {
await updateSemaphore.wait()
defer { updateSemaphore.signal() }

await MainActor.run {
self.isLoading = true
}

let result: R? = if let database { try? await fetcher(database) } else { nil }

await MainActor.run {
self.result = result
self.isLoading = false
if !self.didLoad { self.didLoad = true }
}
}
}

// MARK: - BlackbirdModelObserver

@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
extension BlackbirdModel {
public typealias Observer = BlackbirdModelObserver<Self>

public var observer: Observer { Observer(multicolumnPrimaryKey: try! primaryKeyValues()) }
}

@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
@Observable
public final class BlackbirdModelObserver<T: BlackbirdModel> {
/// Whether this instance is currently loading from the database, either initially or after an update.
public var isLoading = false

/// Whether this instance has ever loaded from the database. After the initial load, it is set to `true` and remains true.
public var didLoad = false

/// The current instance matching the supplied primary-key value.
public var instance: T?

@ObservationIgnored private weak var database: Blackbird.Database?
@ObservationIgnored private var multicolumnPrimaryKey: [Blackbird.Value]?
@ObservationIgnored private var observer: AnyCancellable? = nil

/// Initializer to track a single-column primary-key value.
public convenience init(in database: Blackbird.Database? = nil, primaryKey: Sendable? = nil) {
self.init(in: database, multicolumnPrimaryKey: [primaryKey])
}

/// Initializer to track a multi-column primary-key value.
public init(in database: Blackbird.Database? = nil, multicolumnPrimaryKey: [Sendable]? = nil) {
self.multicolumnPrimaryKey = multicolumnPrimaryKey?.map { try! Blackbird.Value.fromAny($0) } ?? nil
bind(to: database)
}

/// Set or change the ``Blackbird/Database`` to read from and monitor for changes.
public func bind(to database: Blackbird.Database?) {
guard let database else { return }
if let oldValue = self.database, oldValue.id == database.id { return }

self.database = database
updateDatabaseObserver()
}

/// Set or change the single-column primary-key value to observe.
public func observe(primaryKey: Sendable? = nil) { observe(multicolumnPrimaryKey: [primaryKey]) }

/// Set or change the multi-column primary-key value to observe.
public func observe(multicolumnPrimaryKey: [Sendable]? = nil) {
let multicolumnPrimaryKey = multicolumnPrimaryKey?.map { try! Blackbird.Value.fromAny($0) } ?? nil
if multicolumnPrimaryKey == self.multicolumnPrimaryKey { return }

self.multicolumnPrimaryKey = multicolumnPrimaryKey
updateDatabaseObserver()
}

private func updateDatabaseObserver() {
observer?.cancel()
observer = nil
instance = nil

guard let database, let multicolumnPrimaryKey else { return }

observer = T.changePublisher(in: database, multicolumnPrimaryKey: multicolumnPrimaryKey).sink { _ in
Task.detached { [weak self] in await self?.update() }
}
Task.detached { [weak self] in await self?.update() }
}

let updateSemaphore = Blackbird.Semaphore(value: 1)
private func update() async {
await updateSemaphore.wait()
defer { updateSemaphore.signal() }

await MainActor.run {
self.isLoading = true
}

let newInstance: T? = if let database, let multicolumnPrimaryKey { try? await T.read(from: database, multicolumnPrimaryKey: multicolumnPrimaryKey) } else { nil }

await MainActor.run {
self.instance = newInstance
self.isLoading = false
if !self.didLoad { self.didLoad = true }
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
A9858C8C2B1D21FA00531BD9 /* ObservationContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */; };
A9E94F87293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */; };
A9E94F89293E2A8E00A89AC3 /* ContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */; };
A9E94F8B293E2A8F00A89AC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9E94F8A293E2A8F00A89AC3 /* Assets.xcassets */; };
Expand All @@ -17,6 +18,7 @@

/* Begin PBXFileReference section */
A92E4F222951FBE700954C3F /* Blackbird */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Blackbird; path = ../..; sourceTree = "<group>"; };
A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationContentViews.swift; sourceTree = "<group>"; };
A9E94F83293E2A8E00A89AC3 /* BlackbirdSwiftUITest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlackbirdSwiftUITest.app; sourceTree = BUILT_PRODUCTS_DIR; };
A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbirdSwiftUITestApp.swift; sourceTree = "<group>"; };
A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViews.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -61,6 +63,7 @@
A9EE2E2A293E2C5600BCEBCE /* BlackbirdTestData.swift */,
A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */,
A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */,
A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */,
A9E94F8A293E2A8F00A89AC3 /* Assets.xcassets */,
A9E94F8C293E2A8F00A89AC3 /* Preview Content */,
);
Expand Down Expand Up @@ -164,6 +167,7 @@
buildActionMask = 2147483647;
files = (
A9E94F89293E2A8E00A89AC3 /* ContentViews.swift in Sources */,
A9858C8C2B1D21FA00531BD9 /* ObservationContentViews.swift in Sources */,
A9EE2E2B293E2C5600BCEBCE /* BlackbirdTestData.swift in Sources */,
A9E94F87293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ import SwiftUI
import Blackbird

struct Post: BlackbirdModel {
// public static var cacheLimit = 1000

@BlackbirdColumn var id: Int64
@BlackbirdColumn var title: String
}
Expand Down Expand Up @@ -82,6 +80,24 @@ struct BlackbirdSwiftUITestApp: App {
} header: {
Text("Bound database")
}

if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) {
Section {
NavigationLink {
ContentViewObservation()
} label: {
Text("Model list")
}

NavigationLink {
PostViewObservation(post: firstPost.observer)
} label: {
Text("Single-model updater")
}
} header: {
Text("Observation")
}
}
}
}
.environment(\.blackbirdDatabase, database)
Expand Down
Loading

0 comments on commit df7a20e

Please sign in to comment.