Skip to content

Commit

Permalink
Initial Sendable compliance
Browse files Browse the repository at this point in the history
  • Loading branch information
Marco Arment committed Dec 8, 2022
1 parent caadd79 commit 75c0760
Show file tree
Hide file tree
Showing 11 changed files with 259 additions and 149 deletions.
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ let package = Package(
targets: [
.target(
name: "Blackbird",
dependencies: []),
dependencies: [],
swiftSettings: [
// .unsafeFlags(["-Xfrontend", "-warn-concurrency"]) // Uncomment for Sendable testing
]
),
.testTarget(
name: "BlackbirdTests",
dependencies: ["Blackbird"]),
Expand Down
40 changes: 34 additions & 6 deletions Sources/Blackbird/Blackbird.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,32 @@ import SQLite3

/// A small, fast, lightweight SQLite database wrapper and model layer.
public class Blackbird {
/// A dictionary of a single table row's values, keyed by their column names.
public typealias Row = Dictionary<String, Blackbird.Value>

/// A dictionary of argument values for a database query, keyed by column names.
public typealias Arguments = Dictionary<String, Blackbird.Value>

/// A set of primary-key values, where each is an array of values (to support multi-column primary keys).
public typealias PrimaryKeyValues = Set<[Blackbird.Value]>

/// A wrapper for SQLite's column data types.
public enum Value: ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByIntegerLiteral, Hashable {
public enum Value: Sendable, ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByIntegerLiteral, Hashable {
case null
case integer(Int64)
case double(Double)
case text(String)
case data(Data)

public enum Error: Swift.Error {
case cannotConvertToValue(Any)
case cannotConvertToValue(Sendable)
}

public func hash(into hasher: inout Hasher) {
hasher.combine(sqliteLiteral())
}

public static func fromAny(_ value: Any?) throws -> Value {
public static func fromAny(_ value: Sendable?) throws -> Value {
guard let value else { return .null }
switch value {
case let v as Value: return v
Expand Down Expand Up @@ -200,7 +205,7 @@ public class Blackbird {

// MARK: - Utilities

internal protocol BlackbirdLock {
internal protocol BlackbirdLock: Sendable {
func lock()
func unlock()
@discardableResult func withLock<R>(_ body: () throws -> R) rethrows -> R where R : Sendable
Expand All @@ -224,13 +229,13 @@ extension Blackbird {
}

@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
fileprivate class UnfairLock: BlackbirdLock {
fileprivate final class UnfairLock: BlackbirdLock {
private let _lock = OSAllocatedUnfairLock()
internal func lock() { _lock.lock() }
internal func unlock() { _lock.unlock() }
}

fileprivate class LegacyUnfairLock: BlackbirdLock {
fileprivate final class LegacyUnfairLock: BlackbirdLock, @unchecked Sendable /* unchecked due to known-safe use of UnsafeMutablePointer */ {
private var _lock: UnsafeMutablePointer<os_unfair_lock>
internal func lock() { os_unfair_lock_lock(_lock) }
internal func unlock() { os_unfair_lock_unlock(_lock) }
Expand All @@ -241,5 +246,28 @@ extension Blackbird {
}
deinit { _lock.deallocate() }
}

final class Locked<T: Sendable>: @unchecked Sendable /* unchecked due to use of internal locking */ {
public var value: T {
get {
return lock.withLock { _value }
}
set {
lock.withLock { _value = newValue }
}
}

private let lock = Lock()
private var _value: T

init(_ initialValue: T) {
_value = initialValue
}

@discardableResult
public func withLock<R>(_ body: (inout T) -> R) -> R where R: Sendable {
return lock.withLock { return body(&_value) }
}
}
}

106 changes: 76 additions & 30 deletions Sources/Blackbird/BlackbirdChanges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
//

import Foundation
import Combine
@preconcurrency import Combine

public extension Blackbird {
/// A Publisher that emits when data in a Blackbird table has changed.
Expand Down Expand Up @@ -67,16 +67,38 @@ public extension Blackbird {

// MARK: - Legacy notifications

/// Posted when data has changed in a table if `sendLegacyChangeNotifications` is set in ``Blackbird/Database``'s `options`.
///
/// The `userInfo` dictionary may contain the following keys:
///
/// * ``legacyChangeNotificationTableKey``: The string value of the changed table's name.
///
/// Always present in `userInfo`.
/// * ``legacyChangeNotificationPrimaryKeyValuesKey``: The affected primary-key values as an array of arrays, where each value in the top-level array contains the array of a single row's primary-key values (to support multi-column primary keys).
///
/// May be present in `userInfo`. If absent, assume that any data in the table may have changed.
///
/// > Note: `legacyChangeNotification` is **not sent by default**.
/// >
/// > It will be sent for a given ``Blackbird/Database`` only if its `options` at creation included `sendLegacyChangeNotifications`.
///
static let legacyChangeNotification = NSNotification.Name("BlackbirdTableChangeNotification")

/// The string value of the changed table's name. Always present in a ``legacyChangeNotification``'s `userInfo` dictionary.
static let legacyChangeNotificationTableKey = "BlackbirdChangedTable"

/// Affected primary-key values by a table change. May be present in a ``legacyChangeNotification``'s `userInfo` dictionary.
///
/// The affected primary-key values are provided as an array of arrays of Objective-C values, where each value in the top-level array contains the array of a single row's primary-key values (to support multi-column primary keys).
static let legacyChangeNotificationPrimaryKeyValuesKey = "BlackbirdChangedPrimaryKeyValues"
}

// MARK: - Change publisher

extension Blackbird.Database {
internal class ChangeReporter {
private var lock = Blackbird.Lock()

internal final class ChangeReporter: @unchecked Sendable /* unchecked due to use of internal locking */ {
private let lock = Blackbird.Lock()
private var flushIsEnqueued = false
private var activeTransactions = Set<Int64>()
private var ignoreWritesToTableName: String? = nil
Expand Down Expand Up @@ -186,58 +208,82 @@ extension Blackbird.Database {
// MARK: - General query cache with Combine publisher

extension Blackbird {
public typealias CachedResultGenerator<T> = ((_ db: Blackbird.Database) async throws -> T)

public class CachedResultPublisher<T> {
public var valuePublisher = CurrentValueSubject<T?, Never>(nil)
/// A function to generate arbitrary results from a database, called from an async throwing context and passed the ``Blackbird/Database`` as its sole argument.
///
/// Used by ``CachedResultPublisher`` and Blackbird's SwiftUI property wrappers.
///
/// ## Examples
///
/// ```swift
/// { try await Post.read(from: $0, id: 123) }
/// ```
/// ```swift
/// { try await $0.query("SELECT COUNT(*) FROM Post") }
/// ```
public typealias CachedResultGenerator<T: Sendable> = (@Sendable (_ db: Blackbird.Database) async throws -> T)

public final class CachedResultPublisher<T: Sendable>: Sendable {
public let valuePublisher = CurrentValueSubject<T?, Never>(nil)

private var cacheLock = Lock()
private var cachedResults: T? = nil
private struct State: Sendable {
fileprivate var cachedResults: T? = nil
fileprivate var tableName: String? = nil
fileprivate var database: Blackbird.Database? = nil
fileprivate var generator: CachedResultGenerator<T>? = nil
fileprivate var tableChangePublisher: AnyCancellable? = nil
}

private var tableName: String? = nil
private var database: Blackbird.Database? = nil
private var generator: CachedResultGenerator<T>? = nil
private var tableChangePublisher: AnyCancellable? = nil
private let config = Locked(State())

public func subscribe(to tableName: String, in database: Blackbird.Database?, generator: CachedResultGenerator<T>?) {
self.tableName = tableName
self.generator = generator
config.withLock {
$0.tableName = tableName
$0.generator = generator
}
self.changeDatabase(database)
enqueueUpdate()
}

private func update(_ cachedResults: T?) async throws {
let state = config.value
let results: T?
if let cachedResults {
if let cachedResults = state.cachedResults {
results = cachedResults
} else {
results = (generator != nil && database != nil ? try await generator!(database!) : nil)
cacheLock.withLock { self.cachedResults = results }
results = (state.generator != nil && state.database != nil ? try await state.generator!(state.database!) : nil)
config.withLock { $0.cachedResults = results }
await MainActor.run {
valuePublisher.send(results)
}
}
}

private func changeDatabase(_ newDatabase: Database?) {
if newDatabase == database { return }
database = newDatabase
self.cacheLock.withLock { self.cachedResults = nil }

if let database, let tableName {
self.tableChangePublisher = database.changeReporter.changePublisher(for: tableName).sink { [weak self] _ in
guard let self else { return }
self.cacheLock.withLock { self.cachedResults = nil }
self.enqueueUpdate()
config.withLock {
if newDatabase == $0.database { return }

$0.database = newDatabase
$0.cachedResults = nil

if let database = $0.database, let tableName = $0.tableName {
$0.tableChangePublisher = database.changeReporter.changePublisher(for: tableName).sink { [weak self] _ in
guard let self else { return }
self.config.withLock { $0.cachedResults = nil }
self.enqueueUpdate()
}
} else {
$0.tableChangePublisher = nil
}
} else {
self.tableChangePublisher = nil
}
}

private func enqueueUpdate() {
let cachedResults = cacheLock.withLock { self.cachedResults }
Task { do { try await self.update(cachedResults) } catch { print("[Blackbird.ModelArrayUpdater<\(String(describing: T.self))>] ⚠️ Error updating: \(error.localizedDescription)") } }
let cachedResults = config.withLock { $0.cachedResults }
Task {
do { try await self.update(cachedResults) }
catch { print("[Blackbird.CachedResultPublisher<\(String(describing: T.self))>] ⚠️ Error updating: \(error.localizedDescription)") }
}
}
}
}
4 changes: 2 additions & 2 deletions Sources/Blackbird/BlackbirdCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ internal class BlackbirdSQLiteEncoder: Encoder {

public var codingPath: [CodingKey] = []
private let storage = Storage()
public var userInfo: [CodingUserInfoKey : Any] = [:]
public var userInfo: [CodingUserInfoKey: Any] = [:]

func sqliteArguments() -> Blackbird.Arguments { return storage.arguments }

Expand Down Expand Up @@ -173,7 +173,7 @@ internal class BlackbirdSQLiteDecoder: Decoder {
}

public var codingPath: [CodingKey] = []
public var userInfo: [CodingUserInfoKey : Any] = [:]
public var userInfo: [CodingUserInfoKey: Any] = [:]

let row: Blackbird.Row
init(_ row: Blackbird.Row) {
Expand Down
Loading

0 comments on commit 75c0760

Please sign in to comment.