diff --git a/Package.swift b/Package.swift index 2bf4688..1129db9 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), diff --git a/Sources/Blackbird/Blackbird.swift b/Sources/Blackbird/Blackbird.swift index d41182d..915106f 100644 --- a/Sources/Blackbird/Blackbird.swift +++ b/Sources/Blackbird/Blackbird.swift @@ -29,12 +29,17 @@ 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 + + /// A dictionary of argument values for a database query, keyed by column names. public typealias Arguments = Dictionary + + /// 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) @@ -42,14 +47,14 @@ public class Blackbird { 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 @@ -200,7 +205,7 @@ public class Blackbird { // MARK: - Utilities -internal protocol BlackbirdLock { +internal protocol BlackbirdLock: Sendable { func lock() func unlock() @discardableResult func withLock(_ body: () throws -> R) rethrows -> R where R : Sendable @@ -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 internal func lock() { os_unfair_lock_lock(_lock) } internal func unlock() { os_unfair_lock_unlock(_lock) } @@ -241,5 +246,28 @@ extension Blackbird { } deinit { _lock.deallocate() } } + + final class Locked: @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(_ body: (inout T) -> R) -> R where R: Sendable { + return lock.withLock { return body(&_value) } + } + } } diff --git a/Sources/Blackbird/BlackbirdChanges.swift b/Sources/Blackbird/BlackbirdChanges.swift index 90de032..943cee9 100644 --- a/Sources/Blackbird/BlackbirdChanges.swift +++ b/Sources/Blackbird/BlackbirdChanges.swift @@ -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. @@ -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() private var ignoreWritesToTableName: String? = nil @@ -186,33 +208,51 @@ extension Blackbird.Database { // MARK: - General query cache with Combine publisher extension Blackbird { - public typealias CachedResultGenerator = ((_ db: Blackbird.Database) async throws -> T) - public class CachedResultPublisher { - public var valuePublisher = CurrentValueSubject(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 = (@Sendable (_ db: Blackbird.Database) async throws -> T) + + public final class CachedResultPublisher: Sendable { + public let valuePublisher = CurrentValueSubject(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? = nil + fileprivate var tableChangePublisher: AnyCancellable? = nil + } - private var tableName: String? = nil - private var database: Blackbird.Database? = nil - private var generator: CachedResultGenerator? = nil - private var tableChangePublisher: AnyCancellable? = nil + private let config = Locked(State()) public func subscribe(to tableName: String, in database: Blackbird.Database?, generator: CachedResultGenerator?) { - 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) } @@ -220,24 +260,30 @@ extension Blackbird { } 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)") } + } } } } diff --git a/Sources/Blackbird/BlackbirdCodable.swift b/Sources/Blackbird/BlackbirdCodable.swift index 9fb7bf8..d6b1bbf 100644 --- a/Sources/Blackbird/BlackbirdCodable.swift +++ b/Sources/Blackbird/BlackbirdCodable.swift @@ -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 } @@ -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) { diff --git a/Sources/Blackbird/BlackbirdDatabase.swift b/Sources/Blackbird/BlackbirdDatabase.swift index 28ba7d8..5c1084c 100644 --- a/Sources/Blackbird/BlackbirdDatabase.swift +++ b/Sources/Blackbird/BlackbirdDatabase.swift @@ -68,7 +68,7 @@ internal protocol BlackbirdQueryable { /// /// ## See also /// ``cancellableTransaction(_:)`` - func transaction(_ action: ((_ core: isolated Blackbird.Database.Core) throws -> Void) ) async throws + func transaction(_ action: (@Sendable (_ core: isolated Blackbird.Database.Core) throws -> Void) ) async throws /// Equivalent to ``transaction(_:)``, but with the ability to cancel without throwing an error. /// - Parameter action: The actions to perform in the transaction. Return `true` to commit the transaction or `false` to roll it back. If an error is thrown, the transaction is rolled back and the error is rethrown to the caller. @@ -87,7 +87,7 @@ internal protocol BlackbirdQueryable { /// return areWeReadyForCommitment /// } /// ``` - func cancellableTransaction(_ action: ((_ core: isolated Blackbird.Database.Core) throws -> Bool) ) async throws + func cancellableTransaction(_ action: (@Sendable (_ core: isolated Blackbird.Database.Core) throws -> Bool) ) async throws /// Queries the database. @@ -114,7 +114,7 @@ internal protocol BlackbirdQueryable { /// "Test Title" // value for title /// ) /// ``` - @discardableResult func query(_ query: String, _ arguments: Any...) async throws -> [Blackbird.Row] + @discardableResult func query(_ query: String, _ arguments: Sendable...) async throws -> [Blackbird.Row] /// Queries the database with an array of arguments. /// - Parameters: @@ -129,7 +129,7 @@ internal protocol BlackbirdQueryable { /// arguments: [1 /* value for state */, "Test Title" /* value for title */] /// ) /// ``` - @discardableResult func query(_ query: String, arguments: [Any]) async throws -> [Blackbird.Row] + @discardableResult func query(_ query: String, arguments: [Sendable]) async throws -> [Blackbird.Row] /// Queries the database using a dictionary of named arguments. /// @@ -145,7 +145,7 @@ internal protocol BlackbirdQueryable { /// arguments: [":state": 1, ":title": "Test Title"] /// ) /// ``` - @discardableResult func query(_ query: String, arguments: [String: Any]) async throws -> [Blackbird.Row] + @discardableResult func query(_ query: String, arguments: [String: Sendable]) async throws -> [Blackbird.Row] } extension Blackbird { @@ -180,7 +180,7 @@ extension Blackbird { /// } /// ``` /// - public class Database: Identifiable, Hashable, Equatable, BlackbirdQueryable { + public final class Database: Identifiable, Hashable, Equatable, BlackbirdQueryable, Sendable { /// Process-unique identifiers for Database instances. Used internally. public typealias InstanceID = Int64 @@ -204,7 +204,7 @@ extension Blackbird { } /// Options for customizing database behavior. - public struct Options: OptionSet { + public struct Options: OptionSet, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } @@ -223,6 +223,24 @@ extension Blackbird { public static let sendLegacyChangeNotifications = Options(rawValue: 1 << 4) } + internal final class InstancePool: Sendable { + private static let lock = Lock() + private static let _nextInstanceID = Locked(0) + private static let pathsOfCurrentInstances = Locked(Set()) + + internal static func nextInstanceID() -> InstanceID { + _nextInstanceID.withLock { $0 += 1; return $0 } + } + + internal static func addInstance(path: String) -> Bool { + pathsOfCurrentInstances.withLock { let (inserted, _) = $0.insert(path) ; return inserted } + } + + internal static func removeInstance(path: String) { + pathsOfCurrentInstances.withLock { $0.remove(path) } + } + } + /// The path to the database file, or `nil` for in-memory databases. public let path: String? @@ -230,21 +248,16 @@ extension Blackbird { public let options: Options internal let core: Core - internal var changeReporter: ChangeReporter + internal let changeReporter: ChangeReporter internal let perfLog: PerformanceLogger - - private static var instanceLock = Lock() - private static var nextInstanceID: InstanceID = 1 - private static var pathsOfCurrentInstances = Set() - private var isClosedLock = Lock() - private var _isClosed = false + private let isClosedLocked = Locked(false) /// Whether ``close()`` has been called on this database yet. Does **not** indicate whether the close operation has completed. /// /// > Note: Once an instance is closed, it is never reopened. public var isClosed: Bool { - get { isClosedLock.withLock { _isClosed } } + get { isClosedLocked.value } } /// Instantiates a new SQLite database in memory, without persisting to a file. @@ -262,12 +275,9 @@ extension Blackbird { /// /// An error will be thrown if another instance exists with the same filename, the database cannot be created, or the linked version of SQLite lacks the required capabilities. public init(path: String, options: Options = []) throws { - id = try Self.instanceLock.withLock { - if !options.contains(.inMemoryDatabase), Self.pathsOfCurrentInstances.contains(path) { throw Error.anotherInstanceExistsWithPath(path: path) } - let id = Self.nextInstanceID - Self.nextInstanceID += 1 - return id - } + let isUniqueInstanceForPath = options.contains(.inMemoryDatabase) || InstancePool.addInstance(path: path) + if !isUniqueInstanceForPath { throw Error.anotherInstanceExistsWithPath(path: path) } + id = InstancePool.nextInstanceID() // Use a local because we can't use self until everything has been initalized let performanceLog = PerformanceLogger(subsystem: Blackbird.loggingSubsystem, category: "Database") @@ -284,23 +294,24 @@ extension Blackbird { var handle: OpaquePointer? = nil let flags: Int32 = (options.contains(.readOnly) ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) | SQLITE_OPEN_NOMUTEX let result = sqlite3_open_v2(self.path ?? ":memory:", &handle, flags, nil) - guard let handle else { throw Error.cannotOpenDatabaseAtPath(path: path, description: "SQLite cannot allocate memory") } + guard let handle else { + if let path = self.path { InstancePool.removeInstance(path: path) } + throw Error.cannotOpenDatabaseAtPath(path: path, description: "SQLite cannot allocate memory") + } guard result == SQLITE_OK else { let code = sqlite3_errcode(handle) let msg = String(cString: sqlite3_errmsg(handle), encoding: .utf8) ?? "(unknown)" sqlite3_close(handle) + if let path = self.path { InstancePool.removeInstance(path: path) } throw Error.cannotOpenDatabaseAtPath(path: path, description: "SQLite error code \(code): \(msg)") } if SQLITE_OK != sqlite3_exec(handle, "PRAGMA journal_mode = WAL", nil, nil, nil) || SQLITE_OK != sqlite3_exec(handle, "PRAGMA synchronous = NORMAL", nil, nil, nil) { sqlite3_close(handle) + if let path = self.path { InstancePool.removeInstance(path: path) } throw Error.unsupportedConfigurationAtPath(path: path) } - if let filePath = self.path { - Self.instanceLock.withLock { Self.pathsOfCurrentInstances.insert(filePath) } - } - core = Core(handle, changeReporter: changeReporter, options: options) perfLog = performanceLog @@ -314,9 +325,7 @@ extension Blackbird { } deinit { - if let path { - Self.instanceLock.withLock { Self.pathsOfCurrentInstances.remove(path) } - } + if let path { InstancePool.removeInstance(path: path) } } /// Close the current database manually. @@ -330,29 +339,27 @@ extension Blackbird { let spState = perfLog.begin(signpost: .closeDatabase) defer { perfLog.end(state: spState) } - isClosedLock.withLock { _isClosed = true } + isClosedLocked.value = true await core.close() - if let path { - Self.instanceLock.withLock { Self.pathsOfCurrentInstances.remove(path) } - } + if let path { InstancePool.removeInstance(path: path) } } // MARK: - Forwarded Core functions public func execute(_ query: String) async throws { try await core.execute(query) } - public func transaction(_ action: ((_ core: isolated Core) throws -> Void) ) async throws { try await core.transaction(action) } + public func transaction(_ action: (@Sendable (_ core: isolated Core) throws -> Void) ) async throws { try await core.transaction(action) } - public func cancellableTransaction(_ action: ((_ core: isolated Core) throws -> Bool) ) async throws { try await core.cancellableTransaction(action) } + public func cancellableTransaction(_ action: (@Sendable (_ core: isolated Core) throws -> Bool) ) async throws { try await core.cancellableTransaction(action) } - @discardableResult public func query(_ query: String) async throws -> [Blackbird.Row] { return try await core.query(query, []) } + @discardableResult public func query(_ query: String) async throws -> [Blackbird.Row] { return try await core.query(query, [Sendable]()) } - @discardableResult public func query(_ query: String, _ arguments: Any...) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } + @discardableResult public func query(_ query: String, _ arguments: Sendable...) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } - @discardableResult public func query(_ query: String, arguments: [Any]) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } + @discardableResult public func query(_ query: String, arguments: [Sendable]) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } - @discardableResult public func query(_ query: String, arguments: [String: Any]) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } + @discardableResult public func query(_ query: String, arguments: [String: Sendable]) async throws -> [Blackbird.Row] { return try await core.query(query, arguments) } public func setArtificialQueryDelay(_ delay: TimeInterval?) async { await core.setArtificialQueryDelay(delay) } @@ -399,14 +406,14 @@ extension Blackbird { artificialQueryDelay = delay } - public func transaction(_ action: ((_ core: isolated Blackbird.Database.Core) throws -> Void) ) throws { + public func transaction(_ action: (@Sendable (_ core: isolated Blackbird.Database.Core) throws -> Void) ) throws { try cancellableTransaction { core in try action(core) return true } } - public func cancellableTransaction(_ action: ((_ core: isolated Blackbird.Database.Core) throws -> Bool) ) throws { + public func cancellableTransaction(_ action: (@Sendable (_ core: isolated Blackbird.Database.Core) throws -> Bool) ) throws { if isClosed { throw Error.databaseIsClosed } let transactionID = nextTransactionID nextTransactionID += 1 @@ -459,13 +466,13 @@ extension Blackbird { } @discardableResult - public func query(_ query: String) throws -> [Blackbird.Row] { return try self.query(query, []) } + public func query(_ query: String) throws -> [Blackbird.Row] { return try self.query(query, [Sendable]()) } @discardableResult - public func query(_ query: String, _ arguments: Any...) throws -> [Blackbird.Row] { return try self.query(query, arguments: arguments) } + public func query(_ query: String, _ arguments: Sendable...) throws -> [Blackbird.Row] { return try self.query(query, arguments: arguments) } @discardableResult - public func query(_ query: String, arguments: [Any]) throws -> [Blackbird.Row] { + public func query(_ query: String, arguments: [Sendable]) throws -> [Blackbird.Row] { if isClosed { throw Error.databaseIsClosed } let statement = try preparedStatement(query) var idx = 1 // SQLite bind-parameter indexes start at 1, not 0! @@ -478,7 +485,7 @@ extension Blackbird { } @discardableResult - public func query(_ query: String, arguments: [String: Any]) throws -> [Blackbird.Row] { + public func query(_ query: String, arguments: [String: Sendable]) throws -> [Blackbird.Row] { if isClosed { throw Error.databaseIsClosed } let statement = try preparedStatement(query) for (name, any) in arguments { diff --git a/Sources/Blackbird/BlackbirdModel.swift b/Sources/Blackbird/BlackbirdModel.swift index f148928..68feced 100644 --- a/Sources/Blackbird/BlackbirdModel.swift +++ b/Sources/Blackbird/BlackbirdModel.swift @@ -110,7 +110,7 @@ import Combine /// `BlackbirdModel` should not be used with tables or queries involving them, /// and their use may cause some features not to behave as expected. /// -public protocol BlackbirdModel: Codable, Equatable, Identifiable { +public protocol BlackbirdModel: Codable, Equatable, Identifiable, Sendable { /// Defines the database schema for models of this type. static var table: Blackbird.Table { get } @@ -135,7 +135,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// For tables with multi-column primary keys, use ``read(from:multicolumnPrimaryKey:)-4jvke``. /// /// For tables with a single-column primary key named `id`, ``read(from:id:)-ii0w`` is more concise. - static func read(from database: Blackbird.Database, primaryKey: Any) async throws -> Self? + static func read(from database: Blackbird.Database, primaryKey: Sendable) async throws -> Self? /// Reads a single instance with the given primary key values from a database. /// - Parameters: @@ -144,7 +144,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Returns: The decoded instance in the table with the given primary key, or `nil` if a corresponding instance doesn't exist in the table. /// /// For tables with single-column primary keys, ``read(from:primaryKey:)-3p0bv`` is more concise. - static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [Any]) async throws -> Self? + static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [Sendable]) async throws -> Self? /// Reads a single instance with the given primary key values from a database. /// - Parameters: @@ -153,7 +153,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Returns: The decoded instance in the table with the given primary key, or `nil` if a corresponding instance doesn't exist in the table. /// /// For tables with single-column primary keys, ``read(from:primaryKey:)-3p0bv`` is more concise. - static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [String: Any]) async throws -> Self? + static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [String: Sendable]) async throws -> Self? /// Reads a single instance with the given primary-key value from a database if the primary key is a single column named `id`. /// - Parameters: @@ -162,7 +162,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Returns: The decoded instance in the table with the given `id`, or `nil` if a corresponding instance doesn't exist in the table. /// /// For tables with other primary-key names, see ``read(from:primaryKey:)-3p0bv`` and ``read(from:multicolumnPrimaryKey:)-4jvke``. - static func read(from database: Blackbird.Database, id: Any) async throws -> Self? + static func read(from database: Blackbird.Database, id: Sendable) async throws -> Self? /// Reads instances from a database using an optional list of arguments. /// @@ -180,7 +180,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// arguments: [1 /* state */, "Test Title" /* title *] /// ) /// ``` - static func read(from database: Blackbird.Database, where queryAfterWhere: String, _ arguments: Any...) async throws -> [Self] + static func read(from database: Blackbird.Database, where queryAfterWhere: String, _ arguments: Sendable...) async throws -> [Self] /// Reads instances from a database using an array of arguments. /// @@ -198,7 +198,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// arguments: [1 /* state */, "Test Title" /* title *] /// ) /// ``` - static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [Any]) async throws -> [Self] + static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [Sendable]) async throws -> [Self] /// Reads instances from a database using a dictionary of named arguments. /// @@ -216,7 +216,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// arguments: [":state": 1, ":title": "Test Title"] /// ) /// ``` - static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [String: Any]) async throws -> [Self] + static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [String: Sendable]) async throws -> [Self] /// Write this instance to a database. /// - Parameter database: The ``Blackbird/Database`` instance to write to. @@ -253,7 +253,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Question-mark placeholders (`?`) for any argument values to be passed to the query. /// - arguments: Values corresponding to any placeholders in the query. /// - Returns: An array of rows matching the query if applicable, or an empty array otherwise. - @discardableResult static func query(in database: Blackbird.Database, _ query: String, _ arguments: Any...) async throws -> [Blackbird.Row] + @discardableResult static func query(in database: Blackbird.Database, _ query: String, _ arguments: Sendable...) async throws -> [Blackbird.Row] /// Executes arbitrary SQL with a placeholder available for this type's table name. /// - Parameters: @@ -263,7 +263,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Question-mark placeholders (`?`) for any argument values to be passed to the query. /// - arguments: An array of values corresponding to any placeholders in the query. /// - Returns: An array of rows matching the query if applicable, or an empty array otherwise. - @discardableResult static func query(in database: Blackbird.Database, _ query: String, arguments: [Any]) async throws -> [Blackbird.Row] + @discardableResult static func query(in database: Blackbird.Database, _ query: String, arguments: [Sendable]) async throws -> [Blackbird.Row] /// Executes arbitrary SQL with a placeholder available for this type's table name. /// - Parameters: @@ -273,7 +273,7 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { /// - Named placeholders prefixed by a colon (`:`), at-sign (`@`), or dollar sign (`$`) as described in the [SQLite documentation](https://www.sqlite.org/c3ref/bind_blob.html). /// - arguments: A dictionary of placeholder names used in the query and their corresponding values. Names must include the prefix character used. /// - Returns: An array of rows matching the query if applicable, or an empty array otherwise. - @discardableResult static func query(in database: Blackbird.Database, _ query: String, arguments: [String: Any]) async throws -> [Blackbird.Row] + @discardableResult static func query(in database: Blackbird.Database, _ query: String, arguments: [String: Sendable]) async throws -> [Blackbird.Row] /// The change publisher for this model's table. /// - Parameter database: The ``Blackbird/Database`` instance to monitor. @@ -290,24 +290,24 @@ public protocol BlackbirdModel: Codable, Equatable, Identifiable { extension BlackbirdModel { public static func changePublisher(in database: Blackbird.Database) -> Blackbird.ChangePublisher { database.changeReporter.changePublisher(for: self.table.name(type: self)) } - public static func read(from database: Blackbird.Database, id: Any) async throws -> Self? { return try await self.read(from: database, where: "id = ?", id).first } + public static func read(from database: Blackbird.Database, id: Sendable) async throws -> Self? { return try await self.read(from: database, where: "id = ?", id).first } - public static func read(from database: Blackbird.Database, primaryKey: Any) async throws -> Self? { return try await self.read(from: database, multicolumnPrimaryKey: [primaryKey]) } + public static func read(from database: Blackbird.Database, primaryKey: Sendable) async throws -> Self? { return try await self.read(from: database, multicolumnPrimaryKey: [primaryKey]) } - public static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [Any]) async throws -> Self? { + public static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [Sendable]) async throws -> Self? { if multicolumnPrimaryKey.count != table.primaryKeys.count { fatalError("Incorrect number of primary-key values provided (\(multicolumnPrimaryKey.count), need \(table.primaryKeys.count)) for table \(table.name(type: self))") } return try await self.read(from: database, where: table.primaryKeys.map { "`\($0.name)` = ?" }.joined(separator: " AND "), multicolumnPrimaryKey).first } - public static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [String: Any]) async throws -> Self? { + public static func read(from database: Blackbird.Database, multicolumnPrimaryKey: [String: Sendable]) async throws -> Self? { if multicolumnPrimaryKey.count != table.primaryKeys.count { fatalError("Incorrect number of primary-key values provided (\(multicolumnPrimaryKey.count), need \(table.primaryKeys.count)) for table \(table.name(type: self))") } var andClauses: [String] = [] - var values: [Any] = [] + var values: [Sendable] = [] for (name, value) in multicolumnPrimaryKey { andClauses.append("`\(name)` = ?") values.append(value) @@ -316,18 +316,18 @@ extension BlackbirdModel { return try await self.read(from: database, where: andClauses.joined(separator: " AND "), arguments: values).first } - public static func read(from database: Blackbird.Database, where queryAfterWhere: String, _ arguments: Any...) async throws -> [Self] { + public static func read(from database: Blackbird.Database, where queryAfterWhere: String, _ arguments: Sendable...) async throws -> [Self] { return try await self.read(from: database, where: queryAfterWhere, arguments: arguments) } - public static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [Any]) async throws -> [Self] { + public static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [Sendable]) async throws -> [Self] { return try await query(in: database, "SELECT * FROM $T WHERE \(queryAfterWhere)", arguments: arguments).map { let decoder = BlackbirdSQLiteDecoder($0) return try Self(from: decoder) } } - public static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [String: Any]) async throws -> [Self] { + public static func read(from database: Blackbird.Database, where queryAfterWhere: String, arguments: [String: Sendable]) async throws -> [Self] { return try await query(in: database, "SELECT * FROM $T WHERE \(queryAfterWhere)", arguments: arguments).map { let decoder = BlackbirdSQLiteDecoder($0) return try Self(from: decoder) @@ -335,18 +335,18 @@ extension BlackbirdModel { } @discardableResult - public static func query(in database: Blackbird.Database, _ query: String, _ arguments: Any...) async throws -> [Blackbird.Row] { + public static func query(in database: Blackbird.Database, _ query: String, _ arguments: Sendable...) async throws -> [Blackbird.Row] { return try await self.query(in: database, query, arguments: arguments) } @discardableResult - public static func query(in database: Blackbird.Database, _ query: String, arguments: [Any]) async throws -> [Blackbird.Row] { + public static func query(in database: Blackbird.Database, _ query: String, arguments: [Sendable]) async throws -> [Blackbird.Row] { try await table.resolveWithDatabase(type: Self.self, database: database, core: database.core) { try validateSchema() } return try await database.core.query(query.replacingOccurrences(of: "$T", with: table.name(type: Self.self)), arguments: arguments) } @discardableResult - public static func query(in database: Blackbird.Database, _ query: String, arguments: [String: Any]) async throws -> [Blackbird.Row] { + public static func query(in database: Blackbird.Database, _ query: String, arguments: [String: Sendable]) async throws -> [Blackbird.Row] { try await table.resolveWithDatabase(type: Self.self, database: database, core: database.core) { try validateSchema() } return try await database.core.query(query.replacingOccurrences(of: "$T", with: table.name(type: Self.self)), arguments: arguments) } @@ -366,7 +366,7 @@ extension BlackbirdModel { try await table.resolveWithDatabase(type: Self.self, database: database, core: database.core) { try validateSchema() } } - private func insertQueryValues() throws -> (sql: String, values: [Any], primaryKeyValues: [Blackbird.Value]?) { + private func insertQueryValues() throws -> (sql: String, values: [Sendable], primaryKeyValues: [Blackbird.Value]?) { let table = Self.table let encoder = BlackbirdSQLiteEncoder() diff --git a/Sources/Blackbird/BlackbirdObjC.swift b/Sources/Blackbird/BlackbirdObjC.swift index 59bb062..e2cd04f 100644 --- a/Sources/Blackbird/BlackbirdObjC.swift +++ b/Sources/Blackbird/BlackbirdObjC.swift @@ -31,13 +31,18 @@ // *************************************************************************************************** // -import Foundation +@preconcurrency import Foundation fileprivate func raiseObjCException(_ error: Error) -> Never { NSException(name: NSExceptionName(rawValue: "BlackbirdException"), reason: error.localizedDescription).raise() fatalError() // will never execute, but tricks Swift into accepting the Never return type } +fileprivate func raiseObjCException(_ message: String) -> Never { + NSException(name: NSExceptionName(rawValue: "BlackbirdException"), reason: message).raise() + fatalError() // will never execute, but tricks Swift into accepting the Never return type +} + extension Blackbird.Value { internal func objcValue() -> NSObject { switch self { @@ -55,7 +60,7 @@ extension Blackbird.Row { } /// Objective-C version of ``Blackbird/ColumnType``. -@objc public enum BlackbirdColumnTypeObjC: Int { +@objc public enum BlackbirdColumnTypeObjC: Int, Sendable { case integer case double case text @@ -72,7 +77,7 @@ extension Blackbird.Row { } /// Objective-C wrapper for ``Blackbird/Column``. -@objc public class BlackbirdColumnObjC: NSObject { +@objc public final class BlackbirdColumnObjC: NSObject, Sendable { @objc public let name: String internal let column: Blackbird.Column @@ -88,7 +93,7 @@ extension Blackbird.Row { } /// Objective-C wrapper for ``Blackbird/Index``. -@objc public class BlackbirdIndexObjC: NSObject { +@objc public final class BlackbirdIndexObjC: NSObject, Sendable { internal let index: Blackbird.Index /// Objective-C version of ``Blackbird/Index/init(columnNames:unique:)``. @@ -102,7 +107,7 @@ extension Blackbird.Row { } /// Objective-C wrapper for ``Blackbird/Table``. -@objc public class BlackbirdTableObjC: NSObject { +@objc public final class BlackbirdTableObjC: NSObject, Sendable { @objc public let name: String @objc public let columnNames: [String] @objc public let primaryKeyColumnNames: [String] @@ -122,12 +127,12 @@ extension Blackbird.Row { } /// Objective-C wrapper for ``Blackbird/Database``. -@objc public class BlackbirdDatabaseObjC: NSObject { +@objc public final class BlackbirdDatabaseObjC: NSObject, Sendable { /// The wrapped database, accessible for use from Swift. public let db: Blackbird.Database - fileprivate var cachedTableNames = Set() + fileprivate let cachedTableNames = Blackbird.Locked(Set()) /// Instantiates a new SQLite database as a file on disk. /// - Parameters: @@ -186,8 +191,19 @@ extension Blackbird.Row { /// - arguments: An array of values corresponding to any placeholders in the query. /// - Returns: An array of dictionaries matching the query if applicable, or an empty array otherwise. Each dictionary is keyed by each row's column names, and `NULL` values are represented as `NSNull.null`. @objc public func query(_ query: String, arguments: [Any]) async -> [[String: NSObject]] { + let checkedArguments: [Sendable] = arguments.map { + switch $0 { + case let v as NSNumber: return v + case let v as NSString: return String(v) + case let v as NSNull: return v + case let v as NSURL: return v + case let v as NSDate: return v + default: raiseObjCException("Cannot convert argument to supported type (query: \(query), argument: \($0)") + } + } + do { - return try await db.query(query, arguments).map { $0.objcRow() } + return try await db.query(query, checkedArguments).map { $0.objcRow() } } catch { raiseObjCException(error) } @@ -197,9 +213,10 @@ extension Blackbird.Row { @objc public func resolve(table: BlackbirdTableObjC) async { do { try await db.transaction { core in - if cachedTableNames.contains(table.name) { return } + let tableName = table.name + if cachedTableNames.withLock({ $0.contains(tableName) }) { return } try table.table.resolveWithDatabaseIsolated(type: Self.self, database: db, core: core, validator: nil) - cachedTableNames.insert(table.name) + cachedTableNames.withLock { $0.insert(tableName) } } } catch { raiseObjCException(error) diff --git a/Sources/Blackbird/BlackbirdPerformanceLogger.swift b/Sources/Blackbird/BlackbirdPerformanceLogger.swift index 1e238a2..9eb432c 100644 --- a/Sources/Blackbird/BlackbirdPerformanceLogger.swift +++ b/Sources/Blackbird/BlackbirdPerformanceLogger.swift @@ -55,7 +55,7 @@ import OSLog extension Blackbird { static let loggingSubsystem = "org.marco.blackbird" - internal struct PerformanceLogger { + internal struct PerformanceLogger: @unchecked Sendable /* waiting for Sendable compliance in OSLog components */ { let log: Logger // The logger object. Exposed so it can be used directly. let post: OSSignposter // The signposter object. Exposed so it can be used directly. diff --git a/Sources/Blackbird/BlackbirdSchema.swift b/Sources/Blackbird/BlackbirdSchema.swift index 015f1d9..5a1f1d9 100644 --- a/Sources/Blackbird/BlackbirdSchema.swift +++ b/Sources/Blackbird/BlackbirdSchema.swift @@ -44,7 +44,7 @@ extension Blackbird { /// `.data` | Empty data /// /// Custom default values for columns are not supported by Blackbird. - public enum ColumnType { + public enum ColumnType: Sendable { /// Stored as a **signed** integer up to 64-bit. /// @@ -94,7 +94,7 @@ extension Blackbird { } /// A column in a ``Table``. - public struct Column: Equatable, Hashable { + public struct Column: Equatable, Hashable, Sendable { enum Error: Swift.Error { case cannotParseColumnDefinition(table: String, description: String) } @@ -147,7 +147,7 @@ extension Blackbird { } /// An index in a ``Table``. - public struct Index: Equatable, Hashable { + public struct Index: Equatable, Hashable, Sendable { public enum Error: Swift.Error { case cannotParseIndexDefinition(definition: String, description: String) } @@ -208,7 +208,7 @@ extension Blackbird { } /// The schema for a SQLite table in a ``Database``. - public struct Table: Hashable { + public struct Table: Hashable, Sendable { enum Error: Swift.Error { case invalidTableDefinition(table: String, description: String) } @@ -231,9 +231,8 @@ extension Blackbird { internal let primaryKeys: [Column] internal let indexes: [Index] - private static var resolvedTablesWithDatabases: [Table: Set] = [:] - private static var resolvedTableNamesInDatabases: [Database.InstanceID : Set] = [:] - private static var resolvedTablesLock = Lock() + private static let resolvedTablesWithDatabases = Locked([Table: Set]()) + private static let resolvedTableNamesInDatabases = Locked([Database.InstanceID : Set]()) /// Defines the schema of an SQLite table in a ``Blackbird/Database`` for a type conforming to ``BlackbirdModel``. /// - Parameters: @@ -290,31 +289,32 @@ extension Blackbird { internal func createIndexStatements(type: T.Type) -> [String] { indexes.map { $0.definition(tableName: name(type: type)) } } - internal func resolveWithDatabase(type: T.Type, database: Database, core: Database.Core, validator: (() throws -> Void)?) async throws { + internal func resolveWithDatabase(type: T.Type, database: Database, core: Database.Core, validator: (@Sendable () throws -> Void)?) async throws { if _isAlreadyResolved(type: type, in: database) { return } try await core.transaction { try _resolveWithDatabaseIsolated(type: type, database: database, core: $0, validator: validator) } } - internal func resolveWithDatabaseIsolated(type: T.Type, database: Database, core: isolated Database.Core, validator: (() throws -> Void)?) throws { + internal func resolveWithDatabaseIsolated(type: T.Type, database: Database, core: isolated Database.Core, validator: (@Sendable () throws -> Void)?) throws { if _isAlreadyResolved(type: type, in: database) { return } try _resolveWithDatabaseIsolated(type: type, database: database, core: core, validator: validator) } internal func _isAlreadyResolved(type: T.Type, in database: Database) -> Bool { - return Self.resolvedTablesLock.withLock { - let alreadyResolved = Self.resolvedTablesWithDatabases[self]?.contains(database.id) ?? false - if !alreadyResolved, case let name = name(type: type), Self.resolvedTableNamesInDatabases[database.id]?.contains(name) ?? false { - fatalError("Multiple BlackbirdModel types cannot use the same table name (\"\(name)\") in one Database") - } - return alreadyResolved + let alreadyResolved = Self.resolvedTablesWithDatabases.withLock { $0[self]?.contains(database.id) ?? false } + if !alreadyResolved, + case let name = name(type: type), + Self.resolvedTableNamesInDatabases.withLock({ $0[database.id]?.contains(name) ?? false }) + { + fatalError("Multiple BlackbirdModel types cannot use the same table name (\"\(name)\") in one Database") } + return alreadyResolved } - private func _resolveWithDatabaseIsolated(type: T.Type, database: Database, core: isolated Database.Core, validator: (() throws -> Void)?) throws { + private func _resolveWithDatabaseIsolated(type: T.Type, database: Database, core: isolated Database.Core, validator: (@Sendable () throws -> Void)?) throws { // Table not created yet - var schemaInDB: Table + let schemaInDB: Table let tableName = name(type: type) do { let existingSchemaInDB = try Table(isolatedCore: core, tableName: tableName) @@ -337,6 +337,7 @@ extension Blackbird { if primaryKeysChanged || currentColumns != targetColumns || currentIndexes != targetIndexes { try core.transaction { core in // drop indexes and columns + var schemaInDB = schemaInDB for indexToDrop in currentIndexes.subtracting(targetIndexes) { try core.execute("DROP INDEX `\(indexToDrop.name)`") } for columnNameToDrop in schemaInDB.columnNames.subtracting(columnNames) { try core.execute("ALTER TABLE `\(tableName)` DROP COLUMN `\(columnNameToDrop)`") } schemaInDB = try Table(isolatedCore: core, tableName: tableName)! @@ -365,13 +366,16 @@ extension Blackbird { if let validator { try validator() } } } + + Self.resolvedTablesWithDatabases.withLock { + if $0[self] == nil { $0[self] = Set() } + $0[self]!.insert(database.id) + } - Self.resolvedTablesLock.lock() - if Self.resolvedTablesWithDatabases[self] == nil { Self.resolvedTablesWithDatabases[self] = Set() } - Self.resolvedTablesWithDatabases[self]!.insert(database.id) - if Self.resolvedTableNamesInDatabases[database.id] == nil { Self.resolvedTableNamesInDatabases[database.id] = Set() } - Self.resolvedTableNamesInDatabases[database.id]!.insert(tableName) - Self.resolvedTablesLock.unlock() + Self.resolvedTableNamesInDatabases.withLock { + if $0[database.id] == nil { $0[database.id] = Set() } + $0[database.id]!.insert(tableName) + } } } } diff --git a/Sources/Blackbird/BlackbirdSwiftUI.swift b/Sources/Blackbird/BlackbirdSwiftUI.swift index 94c5171..d8f2d8f 100644 --- a/Sources/Blackbird/BlackbirdSwiftUI.swift +++ b/Sources/Blackbird/BlackbirdSwiftUI.swift @@ -178,7 +178,7 @@ extension BlackbirdModel { // MARK: - Multi-row query updaters extension Blackbird { - public class QueryUpdater { + public final class QueryUpdater { @Binding public var results: [Blackbird.Row] @Binding public var didLoad: Bool @@ -210,7 +210,7 @@ extension Blackbird { } } - public class ModelArrayUpdater { + public final class ModelArrayUpdater { @Binding public var results: [T] @Binding public var didLoad: Bool @@ -280,7 +280,7 @@ extension Blackbird { // MARK: - Single-instance updater extension Blackbird { - public class ModelInstanceUpdater { + public final class ModelInstanceUpdater { @Binding public var instance: T? @Binding public var didLoad: Bool @@ -302,7 +302,7 @@ extension Blackbird { /// - primaryKey: The single-column primary-key value to match. /// /// See also: ``bind(from:to:didLoad:multicolumnPrimaryKey:)`` and ``bind(from:to:didLoad:id:)``. - public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, primaryKey: Any) { + public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, primaryKey: Sendable) { watchedPrimaryKeys = Blackbird.PrimaryKeyValues([ [try! Blackbird.Value.fromAny(primaryKey)] ]) bind(from: database, to: instance, didLoad: didLoad) { try await T.read(from: $0, multicolumnPrimaryKey: [primaryKey]) } } @@ -315,7 +315,7 @@ extension Blackbird { /// - multicolumnPrimaryKey: The multi-column primary-key values to match. /// /// See also: ``bind(from:to:didLoad:primaryKey:)`` and ``bind(from:to:didLoad:id:)``. - public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, multicolumnPrimaryKey: [Any]) { + public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, multicolumnPrimaryKey: [Sendable]) { watchedPrimaryKeys = Blackbird.PrimaryKeyValues([ multicolumnPrimaryKey.map { try! Blackbird.Value.fromAny($0) } ]) bind(from: database, to: instance, didLoad: didLoad) { try await T.read(from: $0, multicolumnPrimaryKey: multicolumnPrimaryKey) } } @@ -328,7 +328,7 @@ extension Blackbird { /// - id: The ID value to match, assuming the table has a single-column primary key named `"id"`. /// /// See also: ``bind(from:to:didLoad:primaryKey:)`` and ``bind(from:to:didLoad:multicolumnPrimaryKey:)`` . - public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, id: Any) { + public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, id: Sendable) { watchedPrimaryKeys = Blackbird.PrimaryKeyValues([ [try! Blackbird.Value.fromAny(id)] ]) bind(from: database, to: instance, didLoad: didLoad) { try await T.read(from: $0, id: id) } } @@ -364,7 +364,10 @@ extension Blackbird { } private func enqueueUpdate() { - Task { do { try await self.update() } catch { print("[Blackbird.ModelInstanceUpdater<\(String(describing: T.self))>] ⚠️ Error updating: \(error.localizedDescription)") } } + Task { + do { try await self.update() } + catch { print("[Blackbird.ModelInstanceUpdater<\(String(describing: T.self))>] ⚠️ Error updating: \(error.localizedDescription)") } + } } } } diff --git a/Tests/BlackbirdTests/BlackbirdTests.swift b/Tests/BlackbirdTests/BlackbirdTests.swift index 635e39c..28a0f8a 100644 --- a/Tests/BlackbirdTests/BlackbirdTests.swift +++ b/Tests/BlackbirdTests/BlackbirdTests.swift @@ -231,10 +231,11 @@ final class BlackbirdTestTests: XCTestCase { let id = TestData.randomInt64() let originalTitle = TestData.randomTitle - var t = TestModel(id: id, title: originalTitle, url: TestData.randomURL, nonColumn: TestData.randomString(length: 32)) + let t = TestModel(id: id, title: originalTitle, url: TestData.randomURL, nonColumn: TestData.randomString(length: 32)) try await t.write(to: db) try await db.cancellableTransaction { core in + var t = t t.title = "new title" try t.writeIsolated(to: db, core: core)