Merge branch 'feature/transaction-observer' into develop
This commit is contained in:
@@ -16,14 +16,8 @@ let package = Package(
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(
|
.package(url: "https://github.com/angd-dev/data-lite-core.git", .upToNextMinor(from: "1.1.0")),
|
||||||
url: "https://github.com/angd-dev/data-lite-core.git",
|
.package(url: "https://github.com/angd-dev/data-lite-coder.git", .upToNextMinor(from: "1.0.0")),
|
||||||
revision: "5c6942bd0b9636b5ac3e550453c07aac843e8416"
|
|
||||||
),
|
|
||||||
.package(
|
|
||||||
url: "https://github.com/angd-dev/data-lite-coder.git",
|
|
||||||
revision: "5aec6ea5784dd5bd098bfa98036fbdc362a8931c"
|
|
||||||
),
|
|
||||||
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0")
|
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -38,10 +32,10 @@ let package = Package(
|
|||||||
name: "DataRaftTests",
|
name: "DataRaftTests",
|
||||||
dependencies: ["DataRaft"],
|
dependencies: ["DataRaft"],
|
||||||
resources: [
|
resources: [
|
||||||
|
.copy("Resources/empty.sql"),
|
||||||
.copy("Resources/migration_1.sql"),
|
.copy("Resources/migration_1.sql"),
|
||||||
.copy("Resources/migration_2.sql"),
|
.copy("Resources/migration_2.sql"),
|
||||||
.copy("Resources/migration_3.sql"),
|
.copy("Resources/migration_3.sql")
|
||||||
.copy("Resources/migration_4.sql")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
35
Sources/DataRaft/Aliases/VersionRepresentable.swift
Normal file
35
Sources/DataRaft/Aliases/VersionRepresentable.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A type that describes a database schema version.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// Types conforming to this alias can be compared, checked for equality, hashed, and safely used
|
||||||
|
/// across concurrent contexts. Such types are typically used to track and manage schema migrations.
|
||||||
|
///
|
||||||
|
/// ## Conformance
|
||||||
|
///
|
||||||
|
/// Conforming types must implement:
|
||||||
|
/// - `Equatable` — for equality checks
|
||||||
|
/// - `Comparable` — for ordering versions
|
||||||
|
/// - `Hashable` — for dictionary/set membership
|
||||||
|
/// - `Sendable` — for concurrency safety
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
///
|
||||||
|
/// Use this type alias when defining custom version types for use with ``VersionStorage``.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// struct SemanticVersion: VersionRepresentable {
|
||||||
|
/// let major: Int
|
||||||
|
/// let minor: Int
|
||||||
|
/// let patch: Int
|
||||||
|
///
|
||||||
|
/// static func < (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
/// if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||||
|
/// if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||||
|
/// return lhs.patch < rhs.patch
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable
|
||||||
204
Sources/DataRaft/Classes/ConnectionService.swift
Normal file
204
Sources/DataRaft/Classes/ConnectionService.swift
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import Foundation
|
||||||
|
import DataLiteCore
|
||||||
|
|
||||||
|
/// A base service responsible for establishing and maintaining a database connection.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// `ConnectionService` provides a managed execution environment for database operations. It handles
|
||||||
|
/// connection creation, configuration, encryption key application, and optional reconnection based
|
||||||
|
/// on the associated key provider’s policy.
|
||||||
|
///
|
||||||
|
/// This class guarantees thread-safe access by executing all operations within a dedicated dispatch
|
||||||
|
/// queue. Subclasses may extend it with additional behaviors, such as transaction management or
|
||||||
|
/// lifecycle event posting.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Creating a Service
|
||||||
|
///
|
||||||
|
/// - ``ConnectionProvider``
|
||||||
|
/// - ``ConnectionConfig``
|
||||||
|
/// - ``init(provider:config:queue:)``
|
||||||
|
/// - ``init(connection:config:queue:)``
|
||||||
|
///
|
||||||
|
/// ### Key Management
|
||||||
|
///
|
||||||
|
/// - ``ConnectionServiceKeyProvider``
|
||||||
|
/// - ``keyProvider``
|
||||||
|
///
|
||||||
|
/// ### Connection Lifecycle
|
||||||
|
///
|
||||||
|
/// - ``setNeedsReconnect()``
|
||||||
|
///
|
||||||
|
/// ### Performing Operations
|
||||||
|
///
|
||||||
|
/// - ``ConnectionServiceProtocol/Perform``
|
||||||
|
/// - ``perform(_:)``
|
||||||
|
open class ConnectionService:
|
||||||
|
ConnectionServiceProtocol,
|
||||||
|
@unchecked Sendable
|
||||||
|
{
|
||||||
|
// MARK: - Typealiases
|
||||||
|
|
||||||
|
/// A closure that creates a new database connection.
|
||||||
|
///
|
||||||
|
/// Used for deferred connection creation. Encapsulates initialization logic, configuration, and
|
||||||
|
/// error handling when opening the database.
|
||||||
|
///
|
||||||
|
/// - Returns: An initialized connection instance.
|
||||||
|
/// - Throws: An error if the connection cannot be created or configured.
|
||||||
|
public typealias ConnectionProvider = () throws -> ConnectionProtocol
|
||||||
|
|
||||||
|
/// A closure that configures a newly created connection.
|
||||||
|
///
|
||||||
|
/// Called after the connection is established and, if applicable, after the encryption key has
|
||||||
|
/// been applied. Use this closure to set PRAGMA options or perform additional initialization
|
||||||
|
/// logic.
|
||||||
|
///
|
||||||
|
/// - Parameter connection: The newly created connection to configure.
|
||||||
|
/// - Throws: An error if configuration fails.
|
||||||
|
public typealias ConnectionConfig = (ConnectionProtocol) throws -> Void
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let provider: ConnectionProvider
|
||||||
|
private let config: ConnectionConfig?
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let queueKey = DispatchSpecificKey<Void>()
|
||||||
|
|
||||||
|
private var shouldReconnect: Bool {
|
||||||
|
keyProvider?.connectionService(shouldReconnect: self) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var needsReconnect: Bool = false
|
||||||
|
private var cachedConnection: ConnectionProtocol?
|
||||||
|
|
||||||
|
private var connection: ConnectionProtocol {
|
||||||
|
get throws {
|
||||||
|
guard let cachedConnection, !needsReconnect else {
|
||||||
|
let connection = try connect()
|
||||||
|
cachedConnection = connection
|
||||||
|
needsReconnect = false
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
return cachedConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider responsible for supplying encryption keys to the service.
|
||||||
|
///
|
||||||
|
/// The key provider may determine whether reconnection is allowed and supply
|
||||||
|
/// the encryption key when the connection is established or restored.
|
||||||
|
public weak var keyProvider: ConnectionServiceKeyProvider?
|
||||||
|
|
||||||
|
// MARK: - Inits
|
||||||
|
|
||||||
|
/// Creates a new connection service.
|
||||||
|
///
|
||||||
|
/// Configures an internal serial queue for thread-safe access to the database. The connection
|
||||||
|
/// itself is not created during initialization — it is established lazily on first use (for
|
||||||
|
/// example, inside ``perform(_:)``).
|
||||||
|
///
|
||||||
|
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
|
||||||
|
/// target of the internal queue.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A closure that returns a new database connection.
|
||||||
|
/// - config: An optional configuration closure called after the connection is established and
|
||||||
|
/// the encryption key is applied.
|
||||||
|
/// - queue: An optional target queue for the internal one.
|
||||||
|
public required init(
|
||||||
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil
|
||||||
|
) {
|
||||||
|
self.provider = provider
|
||||||
|
self.config = config
|
||||||
|
self.queue = .init(for: Self.self, qos: .utility)
|
||||||
|
self.queue.setSpecific(key: queueKey, value: ())
|
||||||
|
if let queue = queue {
|
||||||
|
self.queue.setTarget(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new connection service using an autoclosure-based provider.
|
||||||
|
///
|
||||||
|
/// This initializer provides a convenient way to wrap an existing connection expression in an
|
||||||
|
/// autoclosure. The connection itself is not created during initialization — it is established
|
||||||
|
/// lazily on first use.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: An autoclosure that returns a new database connection.
|
||||||
|
/// - config: An optional configuration closure called after the connection is established and
|
||||||
|
/// the encryption key is applied.
|
||||||
|
/// - queue: An optional target queue for the internal one.
|
||||||
|
public required convenience init(
|
||||||
|
connection provider: @escaping @autoclosure ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil
|
||||||
|
) {
|
||||||
|
self.init(provider: provider, config: config, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection Lifecycle
|
||||||
|
|
||||||
|
/// Marks the service as requiring reconnection before the next operation.
|
||||||
|
///
|
||||||
|
/// The reconnection behavior depends on the key provider’s implementation of
|
||||||
|
/// ``ConnectionServiceKeyProvider/connectionService(shouldReconnect:)``. If reconnection is
|
||||||
|
/// allowed, the next access to the connection will create and configure a new one.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` if the reconnection flag was set; otherwise, `false`.
|
||||||
|
@discardableResult
|
||||||
|
public func setNeedsReconnect() -> Bool {
|
||||||
|
switch DispatchQueue.getSpecific(key: queueKey) {
|
||||||
|
case .none:
|
||||||
|
return queue.sync { setNeedsReconnect() }
|
||||||
|
case .some:
|
||||||
|
guard shouldReconnect else { return false }
|
||||||
|
needsReconnect = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performing Operations
|
||||||
|
|
||||||
|
/// Executes a closure within the context of a managed database connection.
|
||||||
|
///
|
||||||
|
/// Runs the operation on the service’s internal queue and ensures that the connection is valid
|
||||||
|
/// before use. If the connection is unavailable or fails during execution, this method throws
|
||||||
|
/// an error.
|
||||||
|
///
|
||||||
|
/// - Parameter closure: The operation to perform using the connection.
|
||||||
|
/// - Returns: The result produced by the closure.
|
||||||
|
/// - Throws: An error thrown by the closure or the connection.
|
||||||
|
public func perform<T>(_ closure: Perform<T>) throws -> T {
|
||||||
|
switch DispatchQueue.getSpecific(key: queueKey) {
|
||||||
|
case .none: try queue.sync { try closure(connection) }
|
||||||
|
case .some: try closure(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal Methods
|
||||||
|
|
||||||
|
func connect() throws -> ConnectionProtocol {
|
||||||
|
let connection = try provider()
|
||||||
|
try applyKey(to: connection)
|
||||||
|
try config?(connection)
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyKey(to connection: ConnectionProtocol) throws {
|
||||||
|
guard let keyProvider = keyProvider else { return }
|
||||||
|
do {
|
||||||
|
let key = try keyProvider.connectionService(keyFor: self)
|
||||||
|
let sql = "SELECT count(*) FROM sqlite_master"
|
||||||
|
try connection.apply(key, name: nil)
|
||||||
|
try connection.execute(sql: sql)
|
||||||
|
} catch {
|
||||||
|
keyProvider.connectionService(self, didReceive: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,313 +1,261 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteC
|
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
import DataLiteC
|
||||||
|
|
||||||
/// Base service for working with a database.
|
/// A base database service that handles transactions and posts change notifications.
|
||||||
///
|
///
|
||||||
/// `DatabaseService` provides a unified interface for performing operations using a database
|
/// ## Overview
|
||||||
/// connection, with built-in support for transactions, reconnection, and optional encryption
|
|
||||||
/// key management.
|
|
||||||
///
|
///
|
||||||
/// The service ensures thread-safe execution by serializing access to the connection through
|
/// `DatabaseService` provides a lightweight transactional layer for performing database operations
|
||||||
/// an internal queue. This enables building modular and safe data access layers without
|
/// within a thread-safe execution context. It automatically detects modifications to the database
|
||||||
/// duplicating low-level logic.
|
/// and posts a ``databaseDidChange`` notification database updates, allowing observers to react to
|
||||||
|
/// updates.
|
||||||
///
|
///
|
||||||
/// The connection is established lazily on first use (e.g., within `perform`), not during
|
/// This class is intended to be subclassed by higher-level data managers that encapsulate domain
|
||||||
/// initialization. If a key provider is set, the key is applied as part of establishing or
|
/// logic while relying on consistent connection and transaction handling.
|
||||||
/// restoring the connection.
|
|
||||||
///
|
///
|
||||||
/// Below is an example of creating a service for managing notes:
|
/// ## Usage
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// final class NoteService: DatabaseService {
|
/// final class NoteService: DatabaseService {
|
||||||
/// func insertNote(_ text: String) throws {
|
/// func insertNote(_ text: String) throws {
|
||||||
/// try perform { connection in
|
/// try perform(in: .deferred) { connection in
|
||||||
/// let stmt = try connection.prepare(sql: "INSERT INTO notes (text) VALUES (?)")
|
/// let sql = "INSERT INTO notes (text) VALUES (?)"
|
||||||
|
/// let stmt = try connection.prepare(sql: sql)
|
||||||
/// try stmt.bind(text, at: 0)
|
/// try stmt.bind(text, at: 0)
|
||||||
/// try stmt.step()
|
/// try stmt.step()
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
///
|
|
||||||
/// func fetchNotes() throws -> [String] {
|
|
||||||
/// try perform { connection in
|
|
||||||
/// let stmt = try connection.prepare(sql: "SELECT text FROM notes")
|
|
||||||
/// var result: [String] = []
|
|
||||||
/// while try stmt.step() {
|
|
||||||
/// if let text: String = stmt.columnValue(at: 0) {
|
|
||||||
/// result.append(text)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// return result
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// let connection = try Connection(location: .inMemory, options: .readwrite)
|
/// let connection = try Connection(location: .inMemory, options: [])
|
||||||
/// let service = NoteService(connection: connection)
|
/// let service = NoteService(connection: connection)
|
||||||
///
|
|
||||||
/// try service.insertNote("Hello, world!")
|
/// try service.insertNote("Hello, world!")
|
||||||
/// let notes = try service.fetchNotes()
|
|
||||||
/// print(notes) // ["Hello, world!"]
|
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// ## Error Handling
|
|
||||||
///
|
|
||||||
/// All operations are executed on an internal serial queue, ensuring thread safety. If a
|
|
||||||
/// decryption error (`SQLITE_NOTADB`) is detected, the service may reopen the connection and
|
|
||||||
/// retry the transactional block exactly once. If the error occurs again, it is propagated
|
|
||||||
/// without further retries.
|
|
||||||
///
|
|
||||||
/// ## Encryption Key Management
|
|
||||||
///
|
|
||||||
/// If a ``keyProvider`` is set, the service uses it to obtain and apply an encryption key when
|
|
||||||
/// establishing or restoring the connection. If an error occurs while obtaining or applying the
|
|
||||||
/// key, the provider is notified through
|
|
||||||
/// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``.
|
|
||||||
///
|
|
||||||
/// ## Reconnection
|
|
||||||
///
|
|
||||||
/// Automatic reconnection is available only during transactional blocks executed with
|
|
||||||
/// ``perform(in:closure:)``. If a decryption error (`SQLITE_NOTADB`) occurs during a
|
|
||||||
/// transaction and the provider allows reconnection, the service obtains a new key, creates a
|
|
||||||
/// new connection, and retries the block once. If the second attempt fails or reconnection is
|
|
||||||
/// disallowed, the error is propagated without further retries.
|
|
||||||
///
|
|
||||||
/// ## Topics
|
/// ## Topics
|
||||||
///
|
///
|
||||||
/// ### Initializers
|
/// ### Initializers
|
||||||
///
|
///
|
||||||
/// - ``ConnectionProvider``
|
/// - ``ConnectionService/ConnectionProvider``
|
||||||
/// - ``ConnectionConfig``
|
/// - ``ConnectionService/ConnectionConfig``
|
||||||
/// - ``init(provider:config:keyProvider:queue:)``
|
/// - ``init(provider:config:queue:center:)``
|
||||||
/// - ``init(connection:config:keyProvider:queue:)``
|
/// - ``init(provider:config:queue:)``
|
||||||
///
|
///
|
||||||
/// ### Key Management
|
/// ### Performing Operations
|
||||||
///
|
///
|
||||||
/// - ``DatabaseServiceKeyProvider``
|
/// - ``ConnectionServiceProtocol/Perform``
|
||||||
/// - ``keyProvider``
|
|
||||||
///
|
|
||||||
/// ### Database Operations
|
|
||||||
///
|
|
||||||
/// - ``DatabaseServiceProtocol/Perform``
|
|
||||||
/// - ``perform(_:)``
|
/// - ``perform(_:)``
|
||||||
/// - ``perform(in:closure:)``
|
/// - ``perform(in:closure:)``
|
||||||
open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable {
|
///
|
||||||
// MARK: - Types
|
/// ### Connection Delegate
|
||||||
|
///
|
||||||
/// A closure that creates a new database connection.
|
/// - ``connection(_:didUpdate:)``
|
||||||
///
|
/// - ``connectionWillCommit(_:)``
|
||||||
/// `ConnectionProvider` is used for deferred connection creation.
|
/// - ``connectionDidRollback(_:)``
|
||||||
/// It allows encapsulating initialization logic, configuration, and
|
///
|
||||||
/// error handling when opening the database.
|
/// ### Notifications
|
||||||
///
|
///
|
||||||
/// - Returns: An initialized `Connection` instance.
|
/// - ``databaseDidChange``
|
||||||
/// - Throws: An error if the connection cannot be created or configured.
|
open class DatabaseService:
|
||||||
public typealias ConnectionProvider = () throws -> Connection
|
ConnectionService,
|
||||||
|
DatabaseServiceProtocol,
|
||||||
/// A closure used to configure a newly created connection.
|
ConnectionDelegate,
|
||||||
///
|
@unchecked Sendable
|
||||||
/// Called after the connection is established (and after key application if present).
|
{
|
||||||
/// Can be used to set PRAGMA options or perform other initialization logic.
|
|
||||||
///
|
|
||||||
/// - Parameter connection: The newly created connection.
|
|
||||||
/// - Throws: Any error if configuration fails.
|
|
||||||
public typealias ConnectionConfig = (Connection) throws -> Void
|
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private let provider: ConnectionProvider
|
private let center: NotificationCenter
|
||||||
private let config: ConnectionConfig?
|
|
||||||
private let queue: DispatchQueue
|
|
||||||
private let queueKey = DispatchSpecificKey<Void>()
|
|
||||||
|
|
||||||
private var cachedConnection: Connection?
|
/// Notification posted after the database content changes with this service.
|
||||||
private var connection: Connection {
|
public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange")
|
||||||
get throws {
|
|
||||||
guard let cachedConnection else {
|
|
||||||
let connection = try connect()
|
|
||||||
cachedConnection = connection
|
|
||||||
return connection
|
|
||||||
}
|
|
||||||
return cachedConnection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encryption key provider.
|
|
||||||
///
|
|
||||||
/// Used to obtain and apply a key when establishing or restoring a connection. The key is
|
|
||||||
/// requested on first access to the connection and on reconnection if needed.
|
|
||||||
public weak var keyProvider: DatabaseServiceKeyProvider?
|
|
||||||
|
|
||||||
// MARK: - Inits
|
// MARK: - Inits
|
||||||
|
|
||||||
/// Creates a new database service.
|
/// Creates a database service with a specified notification center.
|
||||||
///
|
///
|
||||||
/// Configures the internal serial queue for thread-safe access to the database.
|
/// Configures an internal serial queue for thread-safe access to the database. The connection
|
||||||
/// The connection is **not** created during initialization. It is established
|
/// itself is not created during initialization — it is established lazily on first use (for
|
||||||
/// lazily on first use (for example, inside `perform`).
|
/// example, inside ``perform(_:)``).
|
||||||
///
|
///
|
||||||
/// The internal queue is always created with QoS `.utility`. If the `queue`
|
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
|
||||||
/// parameter is provided, it is used as the target queue for the internal one.
|
/// target of the internal queue.
|
||||||
///
|
|
||||||
/// If a `keyProvider` is set, the encryption key will be applied when the
|
|
||||||
/// connection is established or restored.
|
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - provider: A closure that returns a new connection.
|
/// - provider: A closure that returns a new database connection.
|
||||||
/// - config: An optional configuration closure called after the connection
|
/// - config: An optional configuration closure called after the connection is established and
|
||||||
/// is created (and after key application if present).
|
/// the encryption key is applied.
|
||||||
/// - keyProvider: An optional encryption key provider.
|
|
||||||
/// - queue: An optional target queue for the internal one.
|
/// - queue: An optional target queue for the internal one.
|
||||||
|
/// - center: The notification center used to post database change notifications.
|
||||||
public init(
|
public init(
|
||||||
provider: @escaping ConnectionProvider,
|
provider: @escaping ConnectionProvider,
|
||||||
config: ConnectionConfig? = nil,
|
config: ConnectionConfig? = nil,
|
||||||
keyProvider: DatabaseServiceKeyProvider? = nil,
|
queue: DispatchQueue? = nil,
|
||||||
|
center: NotificationCenter
|
||||||
|
) {
|
||||||
|
self.center = center
|
||||||
|
super.init(provider: provider, config: config, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a database service using the default database notification center.
|
||||||
|
///
|
||||||
|
/// Configures an internal serial queue for thread-safe access to the database. The connection
|
||||||
|
/// itself is not created during initialization — it is established lazily on first use (for
|
||||||
|
/// example, inside ``perform(_:)``).
|
||||||
|
///
|
||||||
|
/// The service posts change notifications through ``Foundation/NotificationCenter/databaseCenter``,
|
||||||
|
/// which provides a shared channel for observing database events across the application.
|
||||||
|
///
|
||||||
|
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
|
||||||
|
/// target of the internal queue.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A closure that returns a new database connection.
|
||||||
|
/// - config: An optional configuration closure called after the connection is established and
|
||||||
|
/// the encryption key is applied.
|
||||||
|
/// - queue: An optional target queue for the internal one.
|
||||||
|
public required init(
|
||||||
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
queue: DispatchQueue? = nil
|
queue: DispatchQueue? = nil
|
||||||
) {
|
) {
|
||||||
self.provider = provider
|
self.center = .databaseCenter
|
||||||
self.config = config
|
super.init(provider: provider, config: config, queue: queue)
|
||||||
self.keyProvider = keyProvider
|
}
|
||||||
self.queue = .init(for: Self.self, qos: .utility)
|
|
||||||
self.queue.setSpecific(key: queueKey, value: ())
|
// MARK: - Performing Operations
|
||||||
if let queue = queue {
|
|
||||||
self.queue.setTarget(queue: queue)
|
/// Executes a closure within the context of a managed database connection.
|
||||||
|
///
|
||||||
|
/// Runs the operation on the service’s internal queue and ensures that the connection is valid
|
||||||
|
/// before use. If the connection is unavailable or fails during execution, this method throws
|
||||||
|
/// an error.
|
||||||
|
///
|
||||||
|
/// After the closure completes, if the database content has changed, the service posts a
|
||||||
|
/// ``databaseDidChange`` notification through its configured notification center.
|
||||||
|
///
|
||||||
|
/// - Parameter closure: The operation to perform using the connection.
|
||||||
|
/// - Returns: The result produced by the closure.
|
||||||
|
/// - Throws: An error thrown by the closure or the connection.
|
||||||
|
public override func perform<T>(_ closure: Perform<T>) throws -> T {
|
||||||
|
try super.perform { connection in
|
||||||
|
let changes = connection.totalChanges
|
||||||
|
defer {
|
||||||
|
if changes != connection.totalChanges {
|
||||||
|
center.post(name: Self.databaseDidChange, object: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try closure(connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new database service.
|
/// Executes a closure inside a transaction when the connection operates in autocommit mode.
|
||||||
///
|
///
|
||||||
/// The connection is created lazily on first use. If a `keyProvider` is set,
|
/// The method begins the requested `TransactionType`, runs the closure, and commits the
|
||||||
/// the key will be applied when the connection is established.
|
/// transaction on success. Failures trigger a rollback. If the SQLite engine reports
|
||||||
|
/// `SQLITE_NOTADB` and the key provider allows reconnection, the service re-establishes the
|
||||||
|
/// connection and retries the closure once, mirroring the behavior described in
|
||||||
|
/// ``DatabaseServiceProtocol``.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - provider: An expression that creates a new connection.
|
/// - transaction: The type of transaction to start (for example, `.deferred`).
|
||||||
/// - config: An optional configuration closure called after the connection
|
/// - closure: The work to run while the transaction is active.
|
||||||
/// is created (and after key application if present).
|
|
||||||
/// - keyProvider: An optional encryption key provider.
|
|
||||||
/// - queue: An optional target queue for the internal one.
|
|
||||||
public convenience init(
|
|
||||||
connection provider: @escaping @autoclosure ConnectionProvider,
|
|
||||||
config: ConnectionConfig? = nil,
|
|
||||||
keyProvider: DatabaseServiceKeyProvider? = nil,
|
|
||||||
queue: DispatchQueue? = nil
|
|
||||||
) {
|
|
||||||
self.init(
|
|
||||||
provider: provider,
|
|
||||||
config: config,
|
|
||||||
keyProvider: keyProvider,
|
|
||||||
queue: queue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Methods
|
|
||||||
|
|
||||||
/// Executes a closure with the current connection.
|
|
||||||
///
|
|
||||||
/// Ensures thread-safe access by running the closure on the internal serial queue.
|
|
||||||
/// The connection is created lazily if needed.
|
|
||||||
///
|
|
||||||
/// - Parameter closure: A closure that takes the active connection.
|
|
||||||
/// - Returns: The value returned by the closure.
|
/// - Returns: The value returned by the closure.
|
||||||
/// - Throws: An error if the connection cannot be created or if the closure throws.
|
/// - Throws: Errors from the closure, transaction handling, or connection management.
|
||||||
final public func perform<T>(_ closure: Perform<T>) throws -> T {
|
|
||||||
try withConnection(closure)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Executes a closure inside a transaction if the connection is in autocommit mode.
|
|
||||||
///
|
///
|
||||||
/// If the connection is in autocommit mode, starts a new transaction of the specified
|
/// - Important: The closure may be executed more than once if a reconnection occurs. Ensure it
|
||||||
/// type, executes the closure, and commits changes on success. If the closure throws
|
/// performs only database operations and does not produce external side effects (such as
|
||||||
/// an error, the transaction is rolled back.
|
/// sending network requests or posting notifications).
|
||||||
///
|
public func perform<T>(
|
||||||
/// If the closure throws `Connection.Error` with code `SQLITE_NOTADB` and reconnection
|
|
||||||
/// is allowed, the service attempts to create a new connection, reapply the key, and
|
|
||||||
/// retries the transaction block once. If the second attempt fails or reconnection
|
|
||||||
/// is disallowed, the error is propagated without further retries.
|
|
||||||
///
|
|
||||||
/// If a transaction is already active (connection not in autocommit mode), the closure
|
|
||||||
/// is executed directly without starting a new transaction.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - transaction: The type of transaction to start.
|
|
||||||
/// - closure: A closure that takes the active connection and returns a result.
|
|
||||||
/// - Returns: The value returned by the closure.
|
|
||||||
/// - Throws: Errors from connection creation, key application, configuration,
|
|
||||||
/// transaction management, or from the closure itself.
|
|
||||||
/// - Important: The closure may be executed more than once. Ensure it is idempotent.
|
|
||||||
final public func perform<T>(
|
|
||||||
in transaction: TransactionType,
|
in transaction: TransactionType,
|
||||||
closure: Perform<T>
|
closure: Perform<T>
|
||||||
) throws -> T {
|
) throws -> T {
|
||||||
try withConnection { connection in
|
try perform { connection in
|
||||||
if connection.isAutocommit {
|
guard connection.isAutocommit else {
|
||||||
do {
|
return try closure(connection)
|
||||||
try connection.beginTransaction(transaction)
|
}
|
||||||
let result = try closure(connection)
|
|
||||||
try connection.commitTransaction()
|
do {
|
||||||
return result
|
try connection.beginTransaction(transaction)
|
||||||
} catch {
|
let result = try closure(connection)
|
||||||
|
try connection.commitTransaction()
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
if !connection.isAutocommit {
|
||||||
try connection.rollbackTransaction()
|
try connection.rollbackTransaction()
|
||||||
guard let error = error as? Connection.Error,
|
}
|
||||||
error.code == SQLITE_NOTADB,
|
|
||||||
shouldReconnect
|
|
||||||
else { throw error }
|
|
||||||
|
|
||||||
try reconnect()
|
guard
|
||||||
|
let error = error as? SQLiteError,
|
||||||
|
error.code == SQLITE_NOTADB,
|
||||||
|
setNeedsReconnect()
|
||||||
|
else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
return try withConnection { connection in
|
return try perform { connection in
|
||||||
do {
|
do {
|
||||||
try connection.beginTransaction(transaction)
|
try connection.beginTransaction(transaction)
|
||||||
let result = try closure(connection)
|
let result = try closure(connection)
|
||||||
try connection.commitTransaction()
|
try connection.commitTransaction()
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
if !connection.isAutocommit {
|
||||||
try connection.rollbackTransaction()
|
try connection.rollbackTransaction()
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return try closure(connection)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - ConnectionDelegate
|
||||||
|
|
||||||
private extension DatabaseService {
|
/// Handles database updates reported by the active connection.
|
||||||
var shouldReconnect: Bool {
|
///
|
||||||
keyProvider?.databaseService(shouldReconnect: self) ?? false
|
/// Called after an SQL statement modifies the database content. Subclasses can override this
|
||||||
|
/// method to observe specific actions (for example, inserts, updates, or deletes).
|
||||||
|
///
|
||||||
|
/// - Important: This method must not execute SQL statements or otherwise alter the connection
|
||||||
|
/// state.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - connection: The connection that performed the update.
|
||||||
|
/// - action: The SQLite action describing the change.
|
||||||
|
open func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func withConnection<T>(_ closure: Perform<T>) throws -> T {
|
/// Called immediately before the connection commits a transaction.
|
||||||
switch DispatchQueue.getSpecific(key: queueKey) {
|
///
|
||||||
case .none: try queue.asyncAndWait { try closure(connection) }
|
/// Subclasses can override this method to perform validation or consistency checks prior to
|
||||||
case .some: try closure(connection)
|
/// committing. Throwing an error cancels the commit and triggers a rollback.
|
||||||
}
|
///
|
||||||
|
/// - Important: This method must not execute SQL statements or otherwise alter the connection
|
||||||
|
/// state.
|
||||||
|
///
|
||||||
|
/// - Parameter connection: The connection preparing to commit.
|
||||||
|
/// - Throws: An error to cancel the commit and roll back the transaction.
|
||||||
|
open func connectionWillCommit(_ connection: any ConnectionProtocol) throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconnect() throws {
|
/// Called after the connection rolls back a transaction.
|
||||||
cachedConnection = try connect()
|
///
|
||||||
|
/// Subclasses can override this method to handle cleanup or recovery logic following a
|
||||||
|
/// rollback.
|
||||||
|
///
|
||||||
|
/// - Important: This method must not execute SQL statements or otherwise alter the connection
|
||||||
|
/// state.
|
||||||
|
///
|
||||||
|
/// - Parameter connection: The connection that rolled back the transaction.
|
||||||
|
open func connectionDidRollback(_ connection: any ConnectionProtocol) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect() throws -> Connection {
|
// MARK: - Internal Methods
|
||||||
let connection = try provider()
|
|
||||||
try applyKey(to: connection)
|
override func connect() throws -> any ConnectionProtocol {
|
||||||
try config?(connection)
|
let connection = try super.connect()
|
||||||
|
connection.add(delegate: self)
|
||||||
return connection
|
return connection
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyKey(to connection: Connection) throws {
|
|
||||||
guard let keyProvider = keyProvider else { return }
|
|
||||||
do {
|
|
||||||
let key = try keyProvider.databaseService(keyFor: self)
|
|
||||||
let sql = "SELECT count(*) FROM sqlite_master"
|
|
||||||
try connection.apply(key)
|
|
||||||
try connection.execute(raw: sql)
|
|
||||||
} catch {
|
|
||||||
keyProvider.databaseService(self, didReceive: error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +1,195 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
|
||||||
/// Thread-safe service for executing ordered database schema migrations.
|
#if os(Windows)
|
||||||
|
import WinSDK
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// A service that executes ordered database schema migrations.
|
||||||
///
|
///
|
||||||
/// `MigrationService` stores registered migrations and applies them sequentially
|
/// ## Overview
|
||||||
/// to update the database schema. Each migration runs only once, in version order,
|
|
||||||
/// based on the current schema version stored in the database.
|
|
||||||
///
|
///
|
||||||
/// The service is generic over:
|
/// This class manages migration registration and applies them sequentially to update the database
|
||||||
/// - `Service`: a database service conforming to ``DatabaseServiceProtocol``
|
/// schema. Each migration corresponds to a specific version and runs only once, ensuring that
|
||||||
/// - `Storage`: a version storage conforming to ``VersionStorage``
|
/// schema upgrades are applied in a consistent, deterministic way.
|
||||||
///
|
///
|
||||||
/// Migrations are identified by version and script URL. Both must be unique
|
/// Migrations are executed within an exclusive transaction — if any step fails, the entire process
|
||||||
/// across all registered migrations.
|
/// is rolled back, leaving the database unchanged.
|
||||||
///
|
///
|
||||||
/// Execution is performed inside a single `.exclusive` transaction, ensuring
|
/// `MigrationService` coordinates the migration process by:
|
||||||
/// that either all pending migrations are applied successfully or none are.
|
/// - Managing a registry of unique migrations.
|
||||||
/// On error, the database state is rolled back to the original version.
|
/// - Reading and writing the current schema version through a ``VersionStorage`` implementation.
|
||||||
|
/// - Executing SQL scripts in ascending version order.
|
||||||
///
|
///
|
||||||
/// This type is safe to use from multiple threads.
|
/// It is safe for concurrent use. Internally, it uses a POSIX mutex to ensure thread-safe
|
||||||
|
/// registration and execution.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// let connection = try Connection(location: .inMemory, options: .readwrite)
|
/// let connection = try Connection(location: .inMemory, options: .readwrite)
|
||||||
/// let storage = UserVersionStorage<BitPackVersion>()
|
/// let storage = UserVersionStorage<SemanticVersion>()
|
||||||
/// let service = MigrationService(service: connectionService, storage: storage)
|
/// let service = MigrationService(provider: { connection }, storage: storage)
|
||||||
///
|
///
|
||||||
/// try service.add(Migration(version: "1.0.0", byResource: "v_1_0_0.sql")!)
|
/// try service.add(Migration(version: "1.0.0", byResource: "v1_0_0.sql")!)
|
||||||
/// try service.add(Migration(version: "1.0.1", byResource: "v_1_0_1.sql")!)
|
/// try service.add(Migration(version: "1.0.1", byResource: "v1_0_1.sql")!)
|
||||||
/// try service.migrate()
|
/// try service.migrate()
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// ### Custom Versions and Storage
|
/// ## Topics
|
||||||
///
|
///
|
||||||
/// You can supply a custom `Version` type conforming to ``VersionRepresentable``
|
/// ### Initializers
|
||||||
/// and a `VersionStorage` implementation that determines how and where the
|
///
|
||||||
/// version is persisted (e.g., `PRAGMA user_version`, metadata table, etc.).
|
/// - ``init(provider:config:queue:storage:)``
|
||||||
|
/// - ``init(provider:config:queue:)``
|
||||||
|
///
|
||||||
|
/// ### Migration Management
|
||||||
|
///
|
||||||
|
/// - ``add(_:)``
|
||||||
|
/// - ``migrate()``
|
||||||
public final class MigrationService<
|
public final class MigrationService<
|
||||||
Service: DatabaseServiceProtocol,
|
|
||||||
Storage: VersionStorage
|
Storage: VersionStorage
|
||||||
>:
|
>:
|
||||||
|
ConnectionService,
|
||||||
MigrationServiceProtocol,
|
MigrationServiceProtocol,
|
||||||
@unchecked Sendable
|
@unchecked Sendable
|
||||||
{
|
{
|
||||||
/// Schema version type used for migration ordering.
|
// MARK: - Typealiases
|
||||||
|
|
||||||
|
/// The type representing schema version ordering.
|
||||||
public typealias Version = Storage.Version
|
public typealias Version = Storage.Version
|
||||||
|
|
||||||
private let service: Service
|
// MARK: - Properties
|
||||||
|
|
||||||
private let storage: Storage
|
private let storage: Storage
|
||||||
private var mutex = pthread_mutex_t()
|
|
||||||
private var migrations = Set<Migration<Version>>()
|
private var migrations = Set<Migration<Version>>()
|
||||||
|
|
||||||
/// Encryption key provider delegated to the underlying database service.
|
#if os(Windows)
|
||||||
public weak var keyProvider: DatabaseServiceKeyProvider? {
|
private var mutex = SRWLOCK()
|
||||||
get { service.keyProvider }
|
#else
|
||||||
set { service.keyProvider = newValue }
|
private var mutex = pthread_mutex_t()
|
||||||
}
|
#endif
|
||||||
|
|
||||||
/// Creates a migration service with the given database service and storage.
|
// MARK: - Inits
|
||||||
|
|
||||||
|
/// Creates a migration service with a specified connection configuration and version storage.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - service: Database service used to execute migrations.
|
/// - provider: A closure that returns a new database connection.
|
||||||
/// - storage: Version storage for reading and writing schema version.
|
/// - config: An optional configuration closure called after the connection is established.
|
||||||
|
/// - queue: An optional target queue for internal database operations.
|
||||||
|
/// - storage: The version storage responsible for reading and writing schema version data.
|
||||||
public init(
|
public init(
|
||||||
service: Service,
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil,
|
||||||
storage: Storage
|
storage: Storage
|
||||||
) {
|
) {
|
||||||
self.service = service
|
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
pthread_mutex_init(&mutex, nil)
|
super.init(provider: provider, config: config, queue: queue)
|
||||||
|
|
||||||
|
#if os(Windows)
|
||||||
|
InitializeSRWLock(&mutex)
|
||||||
|
#else
|
||||||
|
pthread_mutex_init(&mutex, nil)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a migration service using the default version storage.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A closure that returns a new database connection.
|
||||||
|
/// - config: An optional configuration closure called after the connection is established.
|
||||||
|
/// - queue: An optional target queue for internal database operations.
|
||||||
|
public required init(
|
||||||
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil
|
||||||
|
) {
|
||||||
|
self.storage = .init()
|
||||||
|
super.init(provider: provider, config: config, queue: queue)
|
||||||
|
|
||||||
|
#if os(Windows)
|
||||||
|
InitializeSRWLock(&mutex)
|
||||||
|
#else
|
||||||
|
pthread_mutex_init(&mutex, nil)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
pthread_mutex_destroy(&mutex)
|
#if !os(Windows)
|
||||||
|
pthread_mutex_destroy(&mutex)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Unsupported
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
public override func setNeedsReconnect() -> Bool {
|
||||||
|
fatalError("Reconnection is not supported for MigrationService.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
public override func perform<T>(_ closure: Perform<T>) throws -> T {
|
||||||
|
fatalError("Direct perform is not supported for MigrationService.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Migration Management
|
||||||
|
|
||||||
/// Registers a new migration, ensuring version and script URL uniqueness.
|
/// Registers a new migration, ensuring version and script URL uniqueness.
|
||||||
///
|
///
|
||||||
/// - Parameter migration: The migration to register.
|
/// - Parameter migration: The migration to register.
|
||||||
/// - Throws: ``MigrationError/duplicateMigration(_:)`` if the migration's
|
/// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version or
|
||||||
/// version or script URL is already registered.
|
/// script URL is already registered.
|
||||||
public func add(_ migration: Migration<Version>) throws(MigrationError<Version>) {
|
public func add(_ migration: Migration<Version>) throws(MigrationError<Version>) {
|
||||||
pthread_mutex_lock(&mutex)
|
#if os(Windows)
|
||||||
defer { pthread_mutex_unlock(&mutex) }
|
AcquireSRWLockExclusive(&mutex)
|
||||||
guard !migrations.contains(where: {
|
defer { ReleaseSRWLockExclusive(&mutex) }
|
||||||
$0.version == migration.version
|
#else
|
||||||
|| $0.scriptURL == migration.scriptURL
|
pthread_mutex_lock(&mutex)
|
||||||
}) else {
|
defer { pthread_mutex_unlock(&mutex) }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
guard
|
||||||
|
!migrations.contains(where: {
|
||||||
|
$0.version == migration.version
|
||||||
|
|| $0.scriptURL == migration.scriptURL
|
||||||
|
})
|
||||||
|
else {
|
||||||
throw .duplicateMigration(migration)
|
throw .duplicateMigration(migration)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations.insert(migration)
|
migrations.insert(migration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes all pending migrations inside a single exclusive transaction.
|
/// Executes all pending migrations in ascending version order.
|
||||||
///
|
///
|
||||||
/// This method retrieves the current schema version from storage, then determines
|
/// The service retrieves the current version from ``VersionStorage``, selects migrations with
|
||||||
/// which migrations have a higher version. The selected migrations are sorted in
|
/// higher versions, sorts them, and executes their scripts inside an exclusive transaction.
|
||||||
/// ascending order and each one's SQL script is executed in sequence. When all
|
|
||||||
/// scripts complete successfully, the stored version is updated to the highest
|
|
||||||
/// applied migration.
|
|
||||||
///
|
///
|
||||||
/// If a script is empty or execution fails, the process aborts and the transaction
|
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty.
|
||||||
/// is rolled back, leaving the database unchanged.
|
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration fails to execute or the
|
||||||
///
|
/// version update cannot be persisted.
|
||||||
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a script is empty.
|
|
||||||
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if execution or version
|
|
||||||
/// update fails.
|
|
||||||
public func migrate() throws(MigrationError<Version>) {
|
public func migrate() throws(MigrationError<Version>) {
|
||||||
pthread_mutex_lock(&mutex)
|
#if os(Windows)
|
||||||
defer { pthread_mutex_unlock(&mutex) }
|
AcquireSRWLockExclusive(&mutex)
|
||||||
|
defer { ReleaseSRWLockExclusive(&mutex) }
|
||||||
|
#else
|
||||||
|
pthread_mutex_lock(&mutex)
|
||||||
|
defer { pthread_mutex_unlock(&mutex) }
|
||||||
|
#endif
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try service.perform(in: .exclusive) { connection in
|
try super.perform { connection in
|
||||||
try storage.prepare(connection)
|
do {
|
||||||
let version = try storage.getVersion(connection)
|
try connection.beginTransaction(.exclusive)
|
||||||
let migrations = migrations
|
try migrate(with: connection)
|
||||||
.filter { $0.version > version }
|
try connection.commitTransaction()
|
||||||
.sorted { $0.version < $1.version }
|
} catch {
|
||||||
|
if !connection.isAutocommit {
|
||||||
for migration in migrations {
|
try connection.rollbackTransaction()
|
||||||
let script = try migration.script
|
|
||||||
guard !script.isEmpty else {
|
|
||||||
throw MigrationError.emptyMigrationScript(migration)
|
|
||||||
}
|
}
|
||||||
do {
|
throw error
|
||||||
try connection.execute(sql: script)
|
|
||||||
} catch {
|
|
||||||
throw MigrationError.migrationFailed(migration, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let version = migrations.last?.version {
|
|
||||||
try storage.setVersion(connection, version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch let error as MigrationError<Version> {
|
} catch let error as MigrationError<Version> {
|
||||||
@@ -139,3 +199,31 @@ public final class MigrationService<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private extension MigrationService {
|
||||||
|
func migrate(with connection: ConnectionProtocol) throws {
|
||||||
|
try storage.prepare(connection)
|
||||||
|
let version = try storage.getVersion(connection)
|
||||||
|
let migrations = migrations
|
||||||
|
.filter { $0.version > version }
|
||||||
|
.sorted { $0.version < $1.version }
|
||||||
|
|
||||||
|
for migration in migrations {
|
||||||
|
let script = try migration.script
|
||||||
|
guard !script.isEmpty else {
|
||||||
|
throw MigrationError.emptyMigrationScript(migration)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try connection.execute(sql: script)
|
||||||
|
} catch {
|
||||||
|
throw MigrationError.migrationFailed(migration, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let version = migrations.last?.version {
|
||||||
|
try storage.setVersion(connection, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
138
Sources/DataRaft/Classes/ModelDatabaseService.swift
Normal file
138
Sources/DataRaft/Classes/ModelDatabaseService.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
import DataLiteCoder
|
||||||
|
|
||||||
|
/// A database service that provides model encoding and decoding support.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// `ModelDatabaseService` extends ``DatabaseService`` by integrating `RowEncoder` and `RowDecoder`
|
||||||
|
/// to simplify model-based interactions with the database. Subclasses can encode Swift types into
|
||||||
|
/// SQLite rows and decode query results back into strongly typed models.
|
||||||
|
///
|
||||||
|
/// This enables a clean, type-safe persistence layer for applications that use Codable or custom
|
||||||
|
/// encodable/decodable types.
|
||||||
|
///
|
||||||
|
/// `ModelDatabaseService` serves as a foundation for higher-level model repositories and services.
|
||||||
|
/// It inherits all transactional and thread-safe behavior from ``DatabaseService`` while adding
|
||||||
|
/// automatic model serialization.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// struct User: Codable {
|
||||||
|
/// let id: Int
|
||||||
|
/// let name: String
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// final class UserService: ModelDatabaseService, @unchecked Sendable {
|
||||||
|
/// func fetchUser() throws -> User? {
|
||||||
|
/// try perform(in: .deferred) { connection in
|
||||||
|
/// let stmt = try connection.prepare(sql: "SELECT * FROM users")
|
||||||
|
/// guard try stmt.step(), let row = stmt.currentRow() else {
|
||||||
|
/// return nil
|
||||||
|
/// }
|
||||||
|
/// return try decoder.decode(User.self, from: row)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// func insertUser(_ user: User) throws {
|
||||||
|
/// try perform(in: .immediate) { connection in
|
||||||
|
/// let row = try encoder.encode(user)
|
||||||
|
/// let columns = row.columns.joined(separator: ", ")
|
||||||
|
/// let placeholders = row.namedParameters.joined(separator: ", ")
|
||||||
|
/// let sql = "INSERT INTO users (\(columns)) VALUES (\(placeholders))"
|
||||||
|
/// let stmt = try connection.prepare(sql: sql)
|
||||||
|
/// try stmt.execute([row])
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Properties
|
||||||
|
///
|
||||||
|
/// - ``encoder``
|
||||||
|
/// - ``decoder``
|
||||||
|
///
|
||||||
|
/// ### Initializers
|
||||||
|
///
|
||||||
|
/// - ``init(provider:config:queue:center:encoder:decoder:)``
|
||||||
|
/// - ``init(provider:config:queue:)``
|
||||||
|
/// - ``init(connection:config:queue:)``
|
||||||
|
open class ModelDatabaseService: DatabaseService, @unchecked Sendable {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// The encoder used to serialize models into row representations.
|
||||||
|
public let encoder: RowEncoder
|
||||||
|
|
||||||
|
/// The decoder used to deserialize database rows into model instances.
|
||||||
|
public let decoder: RowDecoder
|
||||||
|
|
||||||
|
// MARK: - Inits
|
||||||
|
|
||||||
|
/// Creates a model-aware database service.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A closure that returns a new database connection.
|
||||||
|
/// - config: Optional configuration for the connection.
|
||||||
|
/// - queue: The dispatch queue used for serializing database operations.
|
||||||
|
/// - center: The notification center used for database events. Defaults to `.databaseCenter`.
|
||||||
|
/// - encoder: The encoder for converting models into SQLite rows.
|
||||||
|
/// - decoder: The decoder for converting rows back into model instances.
|
||||||
|
public init(
|
||||||
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil,
|
||||||
|
center: NotificationCenter = .databaseCenter,
|
||||||
|
encoder: RowEncoder,
|
||||||
|
decoder: RowDecoder
|
||||||
|
) {
|
||||||
|
self.encoder = encoder
|
||||||
|
self.decoder = decoder
|
||||||
|
super.init(
|
||||||
|
provider: provider,
|
||||||
|
config: config,
|
||||||
|
queue: queue,
|
||||||
|
center: center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a model-aware database service using default encoder and decoder instances.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A closure that returns a new database connection.
|
||||||
|
/// - config: Optional configuration for the connection.
|
||||||
|
/// - queue: The dispatch queue used for serializing database operations.
|
||||||
|
public required init(
|
||||||
|
provider: @escaping ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil
|
||||||
|
) {
|
||||||
|
self.encoder = .init()
|
||||||
|
self.decoder = .init()
|
||||||
|
super.init(
|
||||||
|
provider: provider,
|
||||||
|
config: config,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a model-aware database service from a connection autoclosure.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - provider: A connection autoclosure that returns a database connection.
|
||||||
|
/// - config: Optional configuration for the connection.
|
||||||
|
/// - queue: The dispatch queue used for serializing database operations.
|
||||||
|
public required convenience init(
|
||||||
|
connection provider: @escaping @autoclosure ConnectionProvider,
|
||||||
|
config: ConnectionConfig? = nil,
|
||||||
|
queue: DispatchQueue? = nil
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
provider: provider,
|
||||||
|
config: config,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import DataLiteCore
|
|
||||||
import DataLiteCoder
|
|
||||||
|
|
||||||
/// A database service that provides built-in row encoding and decoding.
|
|
||||||
///
|
|
||||||
/// `RowDatabaseService` extends `DatabaseService` by adding support for
|
|
||||||
/// value serialization using `RowEncoder` and deserialization using `RowDecoder`.
|
|
||||||
///
|
|
||||||
/// This enables subclasses to perform type-safe operations on models
|
|
||||||
/// encoded from or decoded into SQLite row representations.
|
|
||||||
///
|
|
||||||
/// For example, a concrete service might define model-aware fetch or insert methods:
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// struct User: Codable {
|
|
||||||
/// let id: Int
|
|
||||||
/// let name: String
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// final class UserService: RowDatabaseService {
|
|
||||||
/// func fetchUsers() throws -> [User] {
|
|
||||||
/// try perform(in: .deferred) { connection in
|
|
||||||
/// let stmt = try connection.prepare(sql: "SELECT * FROM users")
|
|
||||||
/// let rows = try stmt.execute()
|
|
||||||
/// return try decoder.decode([User].self, from: rows)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// func insertUser(_ user: User) throws {
|
|
||||||
/// try perform(in: .deferred) { connection in
|
|
||||||
/// let row = try encoder.encode(user)
|
|
||||||
/// let columns = row.columns.joined(separator: ", ")
|
|
||||||
/// let parameters = row.namedParameters.joined(separator: ", ")
|
|
||||||
/// let stmt = try connection.prepare(
|
|
||||||
/// sql: "INSERT INTO users (\(columns)) VALUES (\(parameters))"
|
|
||||||
/// )
|
|
||||||
/// try stmt.execute(rows: [row])
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// `RowDatabaseService` encourages a reusable, type-safe pattern for
|
|
||||||
/// model-based interaction with SQLite while preserving thread safety
|
|
||||||
/// and transactional integrity.
|
|
||||||
open class RowDatabaseService:
|
|
||||||
DatabaseService,
|
|
||||||
RowDatabaseServiceProtocol,
|
|
||||||
@unchecked Sendable
|
|
||||||
{
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
/// The encoder used to serialize values into row representations.
|
|
||||||
public let encoder: RowEncoder
|
|
||||||
|
|
||||||
/// The decoder used to deserialize row values into strongly typed models.
|
|
||||||
public let decoder: RowDecoder
|
|
||||||
|
|
||||||
// MARK: - Inits
|
|
||||||
|
|
||||||
/// Creates a new `RowDatabaseService`.
|
|
||||||
///
|
|
||||||
/// This initializer accepts a closure that supplies the database connection. If no encoder
|
|
||||||
/// or decoder is provided, default instances are used.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - provider: A closure that returns a `Connection` instance. May throw an error.
|
|
||||||
/// - encoder: The encoder used to serialize models into SQLite-compatible rows.
|
|
||||||
/// Defaults to a new encoder.
|
|
||||||
/// - decoder: The decoder used to deserialize SQLite rows into typed models.
|
|
||||||
/// Defaults to a new decoder.
|
|
||||||
/// - queue: An optional dispatch queue used for serialization. If `nil`, an internal
|
|
||||||
/// serial queue with `.utility` QoS is created.
|
|
||||||
/// - Throws: Any error thrown by the connection provider.
|
|
||||||
public convenience init(
|
|
||||||
connection provider: @escaping @autoclosure ConnectionProvider,
|
|
||||||
encoder: RowEncoder = RowEncoder(),
|
|
||||||
decoder: RowDecoder = RowDecoder(),
|
|
||||||
queue: DispatchQueue? = nil
|
|
||||||
) {
|
|
||||||
self.init(
|
|
||||||
provider: provider,
|
|
||||||
encoder: encoder,
|
|
||||||
decoder: decoder,
|
|
||||||
queue: queue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Designated initializer for `RowDatabaseService`.
|
|
||||||
///
|
|
||||||
/// Initializes a new instance with the specified connection provider, encoder, decoder,
|
|
||||||
/// and an optional dispatch queue for synchronization.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - provider: A closure that returns a `Connection` instance. May throw an error.
|
|
||||||
/// - encoder: A custom `RowEncoder` used for encoding model data. Defaults to a new encoder.
|
|
||||||
/// - decoder: A custom `RowDecoder` used for decoding database rows. Defaults to a new decoder.
|
|
||||||
/// - queue: An optional dispatch queue for serializing access to the database connection.
|
|
||||||
/// If `nil`, a default internal serial queue with `.utility` QoS is used.
|
|
||||||
/// - Throws: Any error thrown by the connection provider.
|
|
||||||
public init(
|
|
||||||
provider: @escaping ConnectionProvider,
|
|
||||||
encoder: RowEncoder = RowEncoder(),
|
|
||||||
decoder: RowDecoder = RowDecoder(),
|
|
||||||
queue: DispatchQueue? = nil
|
|
||||||
) {
|
|
||||||
self.encoder = encoder
|
|
||||||
self.decoder = decoder
|
|
||||||
super.init(
|
|
||||||
provider: provider,
|
|
||||||
queue: queue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,56 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
|
||||||
/// A database version storage that uses the `user_version` field.
|
/// A version storage that persists schema versions in SQLite’s `user_version` field.
|
||||||
///
|
///
|
||||||
/// This class implements ``VersionStorage`` by storing version information
|
/// ## Overview
|
||||||
/// in the SQLite `PRAGMA user_version` field. It provides a lightweight,
|
|
||||||
/// type-safe way to persist versioning data in a database.
|
|
||||||
///
|
///
|
||||||
/// The generic `Version` type must conform to both ``VersionRepresentable``
|
/// `UserVersionStorage` provides a lightweight, type-safe implementation of ``VersionStorage`` that
|
||||||
/// and `RawRepresentable`, where `RawValue == UInt32`. This allows
|
/// stores version data using the SQLite `PRAGMA user_version` mechanism. This approach is simple,
|
||||||
/// converting between stored integer values and semantic version types
|
/// efficient, and requires no additional tables.
|
||||||
/// defined by the application.
|
///
|
||||||
|
/// The generic `Version` type must conform to both ``VersionRepresentable`` and `RawRepresentable`,
|
||||||
|
/// with `RawValue == UInt32`. This enables conversion between stored integer
|
||||||
|
/// values and the application’s semantic version type.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Errors
|
||||||
|
///
|
||||||
|
/// - ``Error``
|
||||||
|
///
|
||||||
|
/// ### Instance Methods
|
||||||
|
///
|
||||||
|
/// - ``getVersion(_:)``
|
||||||
|
/// - ``setVersion(_:_:)``
|
||||||
public final class UserVersionStorage<
|
public final class UserVersionStorage<
|
||||||
Version: VersionRepresentable & RawRepresentable
|
Version: VersionRepresentable & RawRepresentable
|
||||||
>: Sendable, VersionStorage where Version.RawValue == UInt32 {
|
>: Sendable, VersionStorage where Version.RawValue == UInt32 {
|
||||||
/// Errors related to reading or decoding the version.
|
/// Errors related to reading or decoding the stored version.
|
||||||
public enum Error: Swift.Error {
|
public enum Error: Swift.Error {
|
||||||
/// The stored `user_version` could not be decoded into a valid `Version` case.
|
/// The stored `user_version` value could not be decoded into a valid `Version`.
|
||||||
|
///
|
||||||
|
/// - Parameter value: The invalid raw `UInt32` value.
|
||||||
case invalidStoredVersion(UInt32)
|
case invalidStoredVersion(UInt32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Inits
|
// MARK: - Inits
|
||||||
|
|
||||||
/// Creates a new user version storage instance.
|
/// Creates a new instance of user version storage.
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
// MARK: - Methods
|
// MARK: - Version Management
|
||||||
|
|
||||||
/// Returns the current version stored in the `user_version` field.
|
/// Returns the current schema version stored in the `user_version` field.
|
||||||
///
|
///
|
||||||
/// This method reads the `PRAGMA user_version` value and attempts to
|
/// Reads the `PRAGMA user_version` value and attempts to decode it into a valid `Version`.
|
||||||
/// decode it into a valid `Version` value. If the stored value is not
|
/// If decoding fails, this method throws an error.
|
||||||
/// recognized, it throws an error.
|
|
||||||
///
|
///
|
||||||
/// - Parameter connection: The database connection.
|
/// - Parameter connection: The active database connection.
|
||||||
/// - Returns: A decoded version value of type `Version`.
|
/// - Returns: The decoded version value.
|
||||||
/// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value
|
/// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value cannot
|
||||||
/// cannot be mapped to a valid `Version` instance.
|
/// be mapped to a valid version case.
|
||||||
public func getVersion(
|
public func getVersion(_ connection: ConnectionProtocol) throws -> Version {
|
||||||
_ connection: Connection
|
|
||||||
) throws -> Version {
|
|
||||||
let raw = UInt32(bitPattern: connection.userVersion)
|
let raw = UInt32(bitPattern: connection.userVersion)
|
||||||
guard let version = Version(rawValue: raw) else {
|
guard let version = Version(rawValue: raw) else {
|
||||||
throw Error.invalidStoredVersion(raw)
|
throw Error.invalidStoredVersion(raw)
|
||||||
@@ -47,20 +58,15 @@ public final class UserVersionStorage<
|
|||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the given version in the `user_version` field.
|
/// Stores the specified schema version in the `user_version` field.
|
||||||
///
|
///
|
||||||
/// This method updates the `PRAGMA user_version` field
|
/// Updates the SQLite `PRAGMA user_version` value with the raw `UInt32` representation of the
|
||||||
/// with the raw `UInt32` value of the provided `Version`.
|
/// provided `Version`.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - connection: The database connection.
|
/// - connection: The active database connection.
|
||||||
/// - version: The version to store.
|
/// - version: The version to store.
|
||||||
public func setVersion(
|
public func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws {
|
||||||
_ connection: Connection,
|
connection.userVersion = .init(bitPattern: version.rawValue)
|
||||||
_ version: Version
|
|
||||||
) throws {
|
|
||||||
connection.userVersion = .init(
|
|
||||||
bitPattern: version.rawValue
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
|
||||||
/// Errors that may occur during migration registration or execution.
|
/// Errors that can occur during database migration registration or execution.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// These errors indicate problems such as duplicate migrations, failed execution, or empty
|
||||||
|
/// migration scripts.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Error Cases
|
||||||
|
/// - ``duplicateMigration(_:)``
|
||||||
|
/// - ``emptyMigrationScript(_:)``
|
||||||
|
/// - ``migrationFailed(_:_:)``
|
||||||
public enum MigrationError<Version: VersionRepresentable>: Error {
|
public enum MigrationError<Version: VersionRepresentable>: Error {
|
||||||
/// A migration with the same version or script URL was already registered.
|
/// Indicates that a migration with the same version or script URL has already been registered.
|
||||||
|
///
|
||||||
|
/// - Parameter migration: The duplicate migration instance.
|
||||||
case duplicateMigration(Migration<Version>)
|
case duplicateMigration(Migration<Version>)
|
||||||
|
|
||||||
/// Migration execution failed, with optional reference to the failed migration.
|
/// Indicates that the migration script is empty.
|
||||||
case migrationFailed(Migration<Version>?, Error)
|
///
|
||||||
|
/// - Parameter migration: The migration whose script is empty.
|
||||||
/// The migration script is empty.
|
|
||||||
case emptyMigrationScript(Migration<Version>)
|
case emptyMigrationScript(Migration<Version>)
|
||||||
|
|
||||||
|
/// Indicates that migration execution failed.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - migration: The migration that failed, if available.
|
||||||
|
/// - error: The underlying error that caused the failure.
|
||||||
|
case migrationFailed(Migration<Version>?, Error)
|
||||||
}
|
}
|
||||||
|
|||||||
9
Sources/DataRaft/Extensions/NotificationCenter.swift
Normal file
9
Sources/DataRaft/Extensions/NotificationCenter.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension NotificationCenter {
|
||||||
|
/// The notification center dedicated to database events.
|
||||||
|
///
|
||||||
|
/// Use this instance to post and observe notifications related to database lifecycle and
|
||||||
|
/// operations instead of using the shared `NotificationCenter.default`.
|
||||||
|
static let databaseCenter = NotificationCenter()
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import DataLiteCore
|
||||||
|
|
||||||
|
/// A type that provides encryption keys to a database connection service.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// This type manages how encryption keys are obtained and applied when establishing or restoring a
|
||||||
|
/// connection. Implementations can use static, dynamic, hardware-backed, or biometric key sources.
|
||||||
|
///
|
||||||
|
/// - The service requests a key when establishing or restoring a connection.
|
||||||
|
/// - If decryption fails, the service may ask whether it should attempt to reconnect.
|
||||||
|
/// - If applying a key fails (for example, the key is invalid or ``connectionService(keyFor:)``
|
||||||
|
/// throws), the error is reported through ``connectionService(_:didReceive:)``.
|
||||||
|
///
|
||||||
|
/// - Important: The provider does not receive general database errors.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Providing Keys and Handling Errors
|
||||||
|
///
|
||||||
|
/// - ``connectionService(keyFor:)``
|
||||||
|
/// - ``connectionService(shouldReconnect:)``
|
||||||
|
/// - ``connectionService(_:didReceive:)``
|
||||||
|
public protocol ConnectionServiceKeyProvider: AnyObject, Sendable {
|
||||||
|
/// Returns the encryption key for the specified database service.
|
||||||
|
///
|
||||||
|
/// - Parameter service: The service requesting the key.
|
||||||
|
/// - Returns: The encryption key.
|
||||||
|
/// - Throws: An error if the key cannot be retrieved.
|
||||||
|
func connectionService(keyFor service: ConnectionServiceProtocol) throws -> Connection.Key
|
||||||
|
|
||||||
|
/// Indicates whether the service should attempt to reconnect if applying the key fails.
|
||||||
|
///
|
||||||
|
/// - Parameter service: The database service.
|
||||||
|
/// - Returns: `true` to attempt reconnection. Defaults to `false`.
|
||||||
|
func connectionService(shouldReconnect service: ConnectionServiceProtocol) -> Bool
|
||||||
|
|
||||||
|
/// Notifies the provider of an error that occurred during key retrieval or application.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - service: The database service reporting the error.
|
||||||
|
/// - error: The error encountered during key retrieval or application.
|
||||||
|
func connectionService(_ service: ConnectionServiceProtocol, didReceive error: Error)
|
||||||
|
}
|
||||||
56
Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift
Normal file
56
Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
import DataLiteCore
|
||||||
|
|
||||||
|
/// A type that manages the lifecycle of a database connection.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
///
|
||||||
|
/// Conforming types implement the mechanisms required to open, configure, reconnect, and safelyuse
|
||||||
|
/// a database connection across multiple threads or tasks. This abstraction allows higher-level
|
||||||
|
/// services to execute operations without dealing with low-level connection handling.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Key Management
|
||||||
|
///
|
||||||
|
/// - ``ConnectionServiceKeyProvider``
|
||||||
|
/// - ``keyProvider``
|
||||||
|
///
|
||||||
|
/// ### Connection Lifecycle
|
||||||
|
///
|
||||||
|
/// - ``setNeedsReconnect()``
|
||||||
|
///
|
||||||
|
/// ### Performing Operations
|
||||||
|
///
|
||||||
|
/// - ``Perform``
|
||||||
|
/// - ``perform(_:)``
|
||||||
|
public protocol ConnectionServiceProtocol: AnyObject, Sendable {
|
||||||
|
/// A closure type that performs an operation using an active database connection.
|
||||||
|
///
|
||||||
|
/// - Parameter connection: The active database connection used for the operation.
|
||||||
|
/// - Returns: The result produced by the closure.
|
||||||
|
/// - Throws: Any error thrown by the closure or connection layer.
|
||||||
|
typealias Perform<T> = (ConnectionProtocol) throws -> T
|
||||||
|
|
||||||
|
/// The provider responsible for supplying encryption keys to the service.
|
||||||
|
var keyProvider: ConnectionServiceKeyProvider? { get set }
|
||||||
|
|
||||||
|
/// Marks the service as requiring reconnection before the next operation.
|
||||||
|
///
|
||||||
|
/// The reconnection behavior depends on the key provider’s implementation of
|
||||||
|
/// ``ConnectionServiceKeyProvider/connectionService(shouldReconnect:)``.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` if the reconnection flag was set; otherwise, `false`.
|
||||||
|
@discardableResult
|
||||||
|
func setNeedsReconnect() -> Bool
|
||||||
|
|
||||||
|
/// Executes a closure within the context of an active database connection.
|
||||||
|
///
|
||||||
|
/// Implementations ensure that a valid connection is available before executing the operation.
|
||||||
|
/// If the connection is not available or fails, this method throws an error.
|
||||||
|
///
|
||||||
|
/// - Parameter closure: The operation to perform using the connection.
|
||||||
|
/// - Returns: The result produced by the closure.
|
||||||
|
/// - Throws: Any error thrown by the closure or the underlying connection.
|
||||||
|
func perform<T>(_ closure: Perform<T>) throws -> T
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import DataLiteCore
|
|
||||||
|
|
||||||
/// A protocol for providing encryption keys to a database service.
|
|
||||||
///
|
|
||||||
/// `DatabaseServiceKeyProvider` is responsible for managing encryption keys used
|
|
||||||
/// by a database service. This makes it possible to implement different strategies for storing
|
|
||||||
/// and retrieving keys: static, dynamic, hardware-backed, biometric, and others.
|
|
||||||
///
|
|
||||||
/// - The service requests a key when establishing or restoring a connection.
|
|
||||||
/// - If decryption fails, the service may ask the provider whether it should attempt to reconnect.
|
|
||||||
/// - If applying a key fails (for example, the key does not match or the
|
|
||||||
/// ``databaseService(keyFor:)`` method throws an error), this error is reported
|
|
||||||
/// to the provider through ``databaseService(_:didReceive:)``.
|
|
||||||
///
|
|
||||||
/// - Important: The provider does not receive notifications about general database errors.
|
|
||||||
///
|
|
||||||
/// ## Topics
|
|
||||||
///
|
|
||||||
/// ### Instance Methods
|
|
||||||
///
|
|
||||||
/// - ``databaseService(keyFor:)``
|
|
||||||
/// - ``databaseService(shouldReconnect:)``
|
|
||||||
/// - ``databaseService(_:didReceive:)``
|
|
||||||
public protocol DatabaseServiceKeyProvider: AnyObject, Sendable {
|
|
||||||
/// Returns the encryption key for the specified database service.
|
|
||||||
///
|
|
||||||
/// This method must either return a valid encryption key or throw an error if
|
|
||||||
/// the key cannot be retrieved.
|
|
||||||
///
|
|
||||||
/// - Parameter service: The service requesting the key.
|
|
||||||
/// - Returns: The encryption key.
|
|
||||||
/// - Throws: An error if the key cannot be retrieved.
|
|
||||||
func databaseService(keyFor service: DatabaseServiceProtocol) throws -> Connection.Key
|
|
||||||
|
|
||||||
/// Indicates whether the service should attempt to reconnect if applying the key fails.
|
|
||||||
///
|
|
||||||
/// - Parameter service: The database service.
|
|
||||||
/// - Returns: `true` to attempt reconnection. Defaults to `false`.
|
|
||||||
func databaseService(shouldReconnect service: DatabaseServiceProtocol) -> Bool
|
|
||||||
|
|
||||||
/// Notifies the provider of an error that occurred while retrieving or applying the key.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - service: The database service reporting the error.
|
|
||||||
/// - error: The error encountered during key retrieval or application.
|
|
||||||
func databaseService(_ service: DatabaseServiceProtocol, didReceive error: Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension DatabaseServiceKeyProvider {
|
|
||||||
func databaseService(shouldReconnect service: DatabaseServiceProtocol) -> Bool { false }
|
|
||||||
func databaseService(_ service: DatabaseServiceProtocol, didReceive error: Error) {}
|
|
||||||
}
|
|
||||||
@@ -1,67 +1,45 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
|
||||||
/// A protocol for a database service.
|
/// A type that extends connection management with transactional database operations.
|
||||||
///
|
///
|
||||||
/// `DatabaseServiceProtocol` defines the core capabilities required for
|
/// ## Overview
|
||||||
/// reliable interaction with a database. Conforming implementations provide
|
|
||||||
/// execution of client closures with a live connection, transaction wrapping,
|
|
||||||
/// reconnection logic, and flexible encryption key management.
|
|
||||||
///
|
///
|
||||||
/// This enables building safe and extensible service layers on top of
|
/// This type builds on ``ConnectionServiceProtocol`` by adding the ability to execute closures
|
||||||
/// a database.
|
/// within explicit transactions. Conforming types manage transaction boundaries and ensure that all
|
||||||
|
/// operations within a transaction are committed or rolled back consistently.
|
||||||
///
|
///
|
||||||
/// ## Topics
|
/// ## Topics
|
||||||
///
|
///
|
||||||
/// ### Key Management
|
/// ### Performing Operations
|
||||||
///
|
///
|
||||||
/// - ``DatabaseServiceKeyProvider``
|
/// - ``ConnectionServiceProtocol/Perform``
|
||||||
/// - ``keyProvider``
|
|
||||||
///
|
|
||||||
/// ### Database Operations
|
|
||||||
///
|
|
||||||
/// - ``Perform``
|
|
||||||
/// - ``perform(_:)``
|
|
||||||
/// - ``perform(in:closure:)``
|
/// - ``perform(in:closure:)``
|
||||||
public protocol DatabaseServiceProtocol: AnyObject, Sendable {
|
public protocol DatabaseServiceProtocol: ConnectionServiceProtocol {
|
||||||
/// A closure executed with an active database connection.
|
/// Executes a closure inside a transaction if the connection is in autocommit mode.
|
||||||
///
|
///
|
||||||
/// Used by the service to safely provide access to `Connection`
|
/// If the connection operates in autocommit mode, this method starts a new transaction of the
|
||||||
/// within the appropriate execution context.
|
/// specified type, executes the closure, and commits the changes on success. If the closure
|
||||||
|
/// throws an error, the transaction is rolled back.
|
||||||
///
|
///
|
||||||
/// - Parameter connection: The active database connection.
|
/// Implementations may attempt to re-establish the connection and reapply the encryption key if
|
||||||
/// - Returns: The value returned by the closure.
|
/// an error indicates a lost or invalid database state (for example, `SQLiteError` with code
|
||||||
/// - Throws: An error if the closure execution fails.
|
/// `SQLITE_NOTADB`). In such cases, the service can retry the transaction block once after a
|
||||||
typealias Perform<T> = (Connection) throws -> T
|
/// successful reconnection. If reconnection fails or is disallowed by the key provider, the
|
||||||
|
/// original error is propagated.
|
||||||
/// The encryption key provider for the database service.
|
|
||||||
///
|
///
|
||||||
/// Enables external management of encryption keys.
|
/// If a transaction is already active, the closure is executed directly without starting a new
|
||||||
/// When set, the service can request a key when establishing or
|
/// transaction.
|
||||||
/// restoring a connection, and can also notify about errors
|
|
||||||
/// encountered while applying a key.
|
|
||||||
var keyProvider: DatabaseServiceKeyProvider? { get set }
|
|
||||||
|
|
||||||
/// Executes the given closure with an active connection.
|
|
||||||
///
|
|
||||||
/// The closure receives the connection and may perform any
|
|
||||||
/// database operations within the current context.
|
|
||||||
///
|
|
||||||
/// - Parameter closure: The closure that accepts a connection.
|
|
||||||
/// - Returns: The value returned by the closure.
|
|
||||||
/// - Throws: An error if one occurs during closure execution.
|
|
||||||
func perform<T>(_ closure: Perform<T>) throws -> T
|
|
||||||
|
|
||||||
/// Executes the given closure within a transaction.
|
|
||||||
///
|
|
||||||
/// If the connection is in autocommit mode, the method automatically
|
|
||||||
/// begins a transaction, executes the closure, and commits the changes.
|
|
||||||
/// In case of failure, the transaction is rolled back.
|
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - transaction: The type of transaction to begin.
|
/// - transaction: The type of transaction to start.
|
||||||
/// - closure: The closure that accepts a connection.
|
/// - closure: A closure that takes the active connection and returns a result.
|
||||||
/// - Returns: The value returned by the closure.
|
/// - Returns: The value returned by the closure.
|
||||||
/// - Throws: An error if one occurs during closure execution.
|
/// - Throws: Errors from connection creation, key application, configuration, transaction
|
||||||
|
/// management, or from the closure itself.
|
||||||
|
///
|
||||||
|
/// - Important: The closure may be executed more than once if a reconnection occurs. Ensure it
|
||||||
|
/// performs only database operations and does not produce external side effects (such as
|
||||||
|
/// sending network requests or posting notifications).
|
||||||
func perform<T>(in transaction: TransactionType, closure: Perform<T>) throws -> T
|
func perform<T>(in transaction: TransactionType, closure: Perform<T>) throws -> T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,58 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol for managing and executing database schema migrations.
|
/// A type that manages and executes database schema migrations.
|
||||||
///
|
///
|
||||||
/// Conforming types are responsible for registering migrations, applying
|
/// ## Overview
|
||||||
/// encryption keys (if required), and executing pending migrations in
|
|
||||||
/// ascending version order.
|
|
||||||
///
|
///
|
||||||
/// Migrations ensure that the database schema evolves consistently across
|
/// Conforming types are responsible for registering migration steps, applying encryption keys
|
||||||
/// application versions without requiring manual intervention.
|
/// (if required), and executing pending migrations in ascending version order. Migrations ensure
|
||||||
|
/// that the database schema evolves consistently across application versions without manual
|
||||||
|
/// intervention.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Associated Types
|
||||||
|
/// - ``Version``
|
||||||
|
///
|
||||||
|
/// ### Properties
|
||||||
|
/// - ``keyProvider``
|
||||||
|
///
|
||||||
|
/// ### Instance Methods
|
||||||
|
/// - ``add(_:)``
|
||||||
|
/// - ``migrate()``
|
||||||
|
/// - ``migrate()-18x5r``
|
||||||
public protocol MigrationServiceProtocol: AnyObject, Sendable {
|
public protocol MigrationServiceProtocol: AnyObject, Sendable {
|
||||||
/// Type representing the schema version used for migrations.
|
/// The type representing a schema version used for migrations.
|
||||||
associatedtype Version: VersionRepresentable
|
associatedtype Version: VersionRepresentable
|
||||||
|
|
||||||
/// Encryption key provider for the database service.
|
/// The provider responsible for supplying encryption keys to the service.
|
||||||
var keyProvider: DatabaseServiceKeyProvider? { get set }
|
var keyProvider: ConnectionServiceKeyProvider? { get set }
|
||||||
|
|
||||||
/// Registers a migration to be executed by the service.
|
/// Registers a migration to be executed by the service.
|
||||||
///
|
///
|
||||||
/// - Parameter migration: The migration to register.
|
/// - Parameter migration: The migration to register.
|
||||||
/// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with
|
/// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version
|
||||||
/// the same version or script URL is already registered.
|
/// or script URL is already registered.
|
||||||
func add(_ migration: Migration<Version>) throws(MigrationError<Version>)
|
func add(_ migration: Migration<Version>) throws(MigrationError<Version>)
|
||||||
|
|
||||||
/// Executes all pending migrations in ascending version order.
|
/// Executes all pending migrations in ascending version order.
|
||||||
///
|
///
|
||||||
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration
|
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty.
|
||||||
/// script is empty.
|
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute
|
||||||
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution
|
/// or update the stored version.
|
||||||
/// or version update fails.
|
|
||||||
func migrate() throws(MigrationError<Version>)
|
func migrate() throws(MigrationError<Version>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 13.0, *)
|
@available(iOS 13.0, macOS 10.15, *)
|
||||||
@available(macOS 10.15, *)
|
|
||||||
public extension MigrationServiceProtocol {
|
public extension MigrationServiceProtocol {
|
||||||
/// Asynchronously executes all pending migrations in ascending order.
|
/// Asynchronously executes all pending migrations in ascending version order.
|
||||||
///
|
///
|
||||||
/// Performs the same logic as ``migrate()``, but runs asynchronously
|
/// Performs the same logic as ``migrate()``, but runs asynchronously on a background task with
|
||||||
/// on a background task with `.utility` priority.
|
/// `.utility` priority.
|
||||||
///
|
///
|
||||||
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration
|
/// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty.
|
||||||
/// script is empty.
|
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute
|
||||||
/// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution
|
/// or update the stored version.
|
||||||
/// or version update fails.
|
|
||||||
func migrate() async throws {
|
func migrate() async throws {
|
||||||
try await Task(priority: .utility) {
|
try await Task(priority: .utility) {
|
||||||
try self.migrate()
|
try self.migrate()
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import DataLiteCoder
|
|
||||||
|
|
||||||
/// A protocol for database services that support row encoding and decoding.
|
|
||||||
///
|
|
||||||
/// Conforming types provide `RowEncoder` and `RowDecoder` instances for serializing
|
|
||||||
/// and deserializing model types to and from SQLite row representations.
|
|
||||||
///
|
|
||||||
/// This enables strongly typed, reusable, and safe access to database records
|
|
||||||
/// using Swift's `Codable` system.
|
|
||||||
public protocol RowDatabaseServiceProtocol: DatabaseServiceProtocol {
|
|
||||||
/// The encoder used to serialize values into database rows.
|
|
||||||
var encoder: RowEncoder { get }
|
|
||||||
|
|
||||||
/// The decoder used to deserialize database rows into typed models.
|
|
||||||
var decoder: RowDecoder { get }
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// A constraint that defines the requirements for a type used as a database schema version.
|
|
||||||
///
|
|
||||||
/// This type alias specifies the minimal set of capabilities a version type must have
|
|
||||||
/// to participate in schema migrations. Conforming types must be:
|
|
||||||
///
|
|
||||||
/// - `Equatable`: to check whether two versions are equal
|
|
||||||
/// - `Comparable`: to compare versions and determine ordering
|
|
||||||
/// - `Hashable`: to use versions as dictionary keys or in sets
|
|
||||||
/// - `Sendable`: to ensure safe use in concurrent contexts
|
|
||||||
///
|
|
||||||
/// Use this alias as a base constraint when defining custom version types
|
|
||||||
/// for use with ``VersionStorage``.
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// struct SemanticVersion: VersionRepresentable {
|
|
||||||
/// let major: Int
|
|
||||||
/// let minor: Int
|
|
||||||
/// let patch: Int
|
|
||||||
///
|
|
||||||
/// static func < (lhs: Self, rhs: Self) -> Bool {
|
|
||||||
/// if lhs.major != rhs.major {
|
|
||||||
/// return lhs.major < rhs.major
|
|
||||||
/// }
|
|
||||||
/// if lhs.minor != rhs.minor {
|
|
||||||
/// return lhs.minor < rhs.minor
|
|
||||||
/// }
|
|
||||||
/// return lhs.patch < rhs.patch
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable
|
|
||||||
@@ -1,36 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
|
|
||||||
/// A protocol that defines how the database version is stored and retrieved.
|
/// A type that defines how a database schema version is stored and retrieved.
|
||||||
///
|
///
|
||||||
/// This protocol decouples the concept of version representation from
|
/// ## Overview
|
||||||
/// the way the version is stored. It enables flexible implementations
|
|
||||||
/// that can store version values in different forms and places.
|
|
||||||
///
|
///
|
||||||
/// The associated `Version` type determines how the version is represented
|
/// This protocol separates the concept of version representation from its persistence mechanism,
|
||||||
/// (e.g. as an integer, a semantic string, or a structured object), while the
|
/// allowing flexible implementations that store version values in different formats or locations.
|
||||||
/// conforming type defines how that version is persisted.
|
|
||||||
///
|
///
|
||||||
/// Use this protocol to implement custom strategies for version tracking:
|
/// The associated ``Version`` type specifies how the version is represented (for example, as an
|
||||||
/// - Store an integer version in SQLite's `user_version` field.
|
/// integer, a semantic string, or a structured object), while the conforming type defines how that
|
||||||
|
/// version is persisted.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
///
|
||||||
|
/// Implement this type to define a custom strategy for schema version tracking:
|
||||||
|
/// - Store an integer version in SQLite’s `user_version` field.
|
||||||
/// - Store a string in a dedicated metadata table.
|
/// - Store a string in a dedicated metadata table.
|
||||||
/// - Store structured data in a JSON column.
|
/// - Store structured data in a JSON column.
|
||||||
///
|
///
|
||||||
/// To define your own versioning mechanism, implement `VersionStorage`
|
/// The example below shows an implementation that stores the version string in a `schema_version`
|
||||||
/// and choose a `Version` type that conforms to ``VersionRepresentable``.
|
/// table:
|
||||||
///
|
|
||||||
/// You can implement this protocol to define a custom way of storing the version
|
|
||||||
/// of a database schema. For example, the version could be a string stored in a metadata table.
|
|
||||||
///
|
|
||||||
/// Below is an example of a simple implementation that stores the version string
|
|
||||||
/// in a table named `schema_version`.
|
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// final class StringVersionStorage: VersionStorage {
|
/// final class StringVersionStorage: VersionStorage {
|
||||||
/// typealias Version = String
|
/// typealias Version = String
|
||||||
///
|
///
|
||||||
/// func prepare(_ connection: Connection) throws {
|
/// func prepare(_ connection: ConnectionProtocol) throws {
|
||||||
/// let script: SQLScript = """
|
/// let script = """
|
||||||
/// CREATE TABLE IF NOT EXISTS schema_version (
|
/// CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
/// version TEXT NOT NULL
|
/// version TEXT NOT NULL
|
||||||
/// );
|
/// );
|
||||||
@@ -42,7 +39,7 @@ import DataLiteCore
|
|||||||
/// try connection.execute(sql: script)
|
/// try connection.execute(sql: script)
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// func getVersion(_ connection: Connection) throws -> Version {
|
/// func getVersion(_ connection: ConnectionProtocol) throws -> Version {
|
||||||
/// let query = "SELECT version FROM schema_version LIMIT 1"
|
/// let query = "SELECT version FROM schema_version LIMIT 1"
|
||||||
/// let stmt = try connection.prepare(sql: query)
|
/// let stmt = try connection.prepare(sql: query)
|
||||||
/// guard try stmt.step(), let value: Version = stmt.columnValue(at: 0) else {
|
/// guard try stmt.step(), let value: Version = stmt.columnValue(at: 0) else {
|
||||||
@@ -51,7 +48,7 @@ import DataLiteCore
|
|||||||
/// return value
|
/// return value
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// func setVersion(_ connection: Connection, _ version: Version) throws {
|
/// func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws {
|
||||||
/// let query = "UPDATE schema_version SET version = ?"
|
/// let query = "UPDATE schema_version SET version = ?"
|
||||||
/// let stmt = try connection.prepare(sql: query)
|
/// let stmt = try connection.prepare(sql: query)
|
||||||
/// try stmt.bind(version, at: 0)
|
/// try stmt.bind(version, at: 0)
|
||||||
@@ -60,17 +57,6 @@ import DataLiteCore
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// This implementation works as follows:
|
|
||||||
///
|
|
||||||
/// - `prepare(_:)` creates the `schema_version` table if it does not exist, and ensures that it
|
|
||||||
/// contains exactly one row with an initial version value (`"0.0.0"`).
|
|
||||||
///
|
|
||||||
/// - `getVersion(_:)` reads the current version string from the single row in the table.
|
|
||||||
/// If the row is missing, it throws an error.
|
|
||||||
///
|
|
||||||
/// - `setVersion(_:_:)` updates the version string in that row. A `WHERE` clause is not necessary
|
|
||||||
/// because the table always contains exactly one row.
|
|
||||||
///
|
|
||||||
/// ## Topics
|
/// ## Topics
|
||||||
///
|
///
|
||||||
/// ### Associated Types
|
/// ### Associated Types
|
||||||
@@ -83,58 +69,54 @@ import DataLiteCore
|
|||||||
/// - ``getVersion(_:)``
|
/// - ``getVersion(_:)``
|
||||||
/// - ``setVersion(_:_:)``
|
/// - ``setVersion(_:_:)``
|
||||||
public protocol VersionStorage {
|
public protocol VersionStorage {
|
||||||
/// A type representing the database schema version.
|
/// The type representing the database schema version.
|
||||||
associatedtype Version: VersionRepresentable
|
associatedtype Version: VersionRepresentable
|
||||||
|
|
||||||
|
/// Creates a new instance of the version storage.
|
||||||
|
init()
|
||||||
|
|
||||||
/// Prepares the storage mechanism for tracking the schema version.
|
/// Prepares the storage mechanism for tracking the schema version.
|
||||||
///
|
///
|
||||||
/// This method is called before any version operations. Use it to create required tables
|
/// Called before any version operations. Use this method to create required tables or metadata
|
||||||
/// or metadata structures needed for version management.
|
/// structures for version management.
|
||||||
///
|
///
|
||||||
/// - Important: This method is executed within an active migration transaction.
|
/// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or
|
||||||
/// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error,
|
/// `COMMIT` manually. If this method throws an error, the migration process will be aborted
|
||||||
/// the entire migration process will be aborted and rolled back.
|
/// and rolled back.
|
||||||
///
|
///
|
||||||
/// - Parameter connection: The database connection used for schema preparation.
|
/// - Parameter connection: The database connection used for schema preparation.
|
||||||
/// - Throws: An error if preparation fails.
|
/// - Throws: An error if preparation fails.
|
||||||
func prepare(_ connection: Connection) throws
|
func prepare(_ connection: ConnectionProtocol) throws
|
||||||
|
|
||||||
/// Returns the current schema version stored in the database.
|
/// Returns the current schema version stored in the database.
|
||||||
///
|
///
|
||||||
/// This method must return a valid version previously stored by the migration system.
|
/// Must return a valid version previously stored by the migration system.
|
||||||
///
|
///
|
||||||
/// - Important: This method is executed within an active migration transaction.
|
/// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or
|
||||||
/// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error,
|
/// `COMMIT` manually. If this method throws an error, the migration process will be aborted
|
||||||
/// the entire migration process will be aborted and rolled back.
|
/// and rolled back.
|
||||||
///
|
///
|
||||||
/// - Parameter connection: The database connection used to fetch the version.
|
/// - Parameter connection: The database connection used to fetch the version.
|
||||||
/// - Returns: The version currently stored in the database.
|
/// - Returns: The version currently stored in the database.
|
||||||
/// - Throws: An error if reading fails or the version is missing.
|
/// - Throws: An error if reading fails or the version is missing.
|
||||||
func getVersion(_ connection: Connection) throws -> Version
|
func getVersion(_ connection: ConnectionProtocol) throws -> Version
|
||||||
|
|
||||||
/// Stores the given version as the current schema version.
|
/// Stores the given version as the current schema version.
|
||||||
///
|
///
|
||||||
/// This method is called at the end of the migration process to persist
|
/// Called at the end of the migration process to persist the final schema version after all
|
||||||
/// the final schema version after all migration steps have completed successfully.
|
/// migration steps complete successfully.
|
||||||
///
|
///
|
||||||
/// - Important: This method is executed within an active migration transaction.
|
/// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or
|
||||||
/// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error,
|
/// `COMMIT` manually. If this method throws an error, the migration process will be aborted
|
||||||
/// the entire migration process will be aborted and rolled back.
|
/// and rolled back.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - connection: The database connection used to write the version.
|
/// - connection: The database connection used to write the version.
|
||||||
/// - version: The version to store.
|
/// - version: The version to store.
|
||||||
/// - Throws: An error if writing fails.
|
/// - Throws: An error if writing fails.
|
||||||
func setVersion(_ connection: Connection, _ version: Version) throws
|
func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension VersionStorage {
|
public extension VersionStorage {
|
||||||
/// A default implementation that performs no preparation.
|
func prepare(_ connection: ConnectionProtocol) throws {}
|
||||||
///
|
|
||||||
/// Override this method if your storage implementation requires any setup,
|
|
||||||
/// such as creating a version table or inserting an initial value.
|
|
||||||
///
|
|
||||||
/// If you override this method and it throws an error, the migration process
|
|
||||||
/// will be aborted and rolled back.
|
|
||||||
func prepare(_ connection: Connection) throws {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStri
|
|||||||
|
|
||||||
// MARK: - ExpressibleByStringLiteral
|
// MARK: - ExpressibleByStringLiteral
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, macOS 13.0, *)
|
||||||
@available(macOS 13.0, *)
|
|
||||||
extension BitPackVersion: ExpressibleByStringLiteral {
|
extension BitPackVersion: ExpressibleByStringLiteral {
|
||||||
/// An error related to parsing a version string.
|
/// An error related to parsing a version string.
|
||||||
public enum ParseError: Swift.Error {
|
public enum ParseError: Swift.Error {
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import DataLiteCore
|
|
||||||
|
|
||||||
/// Represents a database migration step associated with a specific version.
|
/// A database migration step for a specific schema version.
|
||||||
///
|
///
|
||||||
/// Each `Migration` contains a reference to a migration script file (usually a `.sql` file) and the
|
/// ## Overview
|
||||||
/// version to which this script corresponds. The script is expected to be bundled with the application.
|
|
||||||
///
|
///
|
||||||
/// You can initialize a migration directly with a URL to the script, or load it from a resource
|
/// Each migration links a version identifier with a script file that modifies the database schema.
|
||||||
/// embedded in a bundle.
|
/// Scripts are typically bundled with the application and executed sequentially during version
|
||||||
|
/// upgrades.
|
||||||
|
///
|
||||||
|
/// ## Topics
|
||||||
|
///
|
||||||
|
/// ### Properties
|
||||||
|
/// - ``version``
|
||||||
|
/// - ``scriptURL``
|
||||||
|
/// - ``script``
|
||||||
|
///
|
||||||
|
/// ### Initializers
|
||||||
|
/// - ``init(version:scriptURL:)``
|
||||||
|
/// - ``init(version:byResource:extension:in:)``
|
||||||
public struct Migration<Version: VersionRepresentable>: Hashable, Sendable {
|
public struct Migration<Version: VersionRepresentable>: Hashable, Sendable {
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
/// The version associated with this migration step.
|
/// The version associated with this migration step.
|
||||||
public let version: Version
|
public let version: Version
|
||||||
|
|
||||||
/// The URL pointing to the migration script (e.g., an SQL file).
|
/// The file URL of the migration script (for example, an SQL file).
|
||||||
public let scriptURL: URL
|
public let scriptURL: URL
|
||||||
|
|
||||||
/// The SQL script associated with this migration.
|
/// The migration script as a string.
|
||||||
///
|
///
|
||||||
/// This computed property reads the contents of the file at `scriptURL` and returns it as a
|
/// Reads the contents of the file at ``scriptURL`` and trims surrounding whitespace and
|
||||||
/// `SQLScript` instance. Use this to access and execute the migration's SQL commands.
|
/// newlines.
|
||||||
///
|
///
|
||||||
/// - Throws: An error if the script file cannot be read or is invalid.
|
/// - Throws: An error if the script file cannot be read.
|
||||||
public var script: SQLScript {
|
public var script: String {
|
||||||
get throws {
|
get throws {
|
||||||
try SQLScript(contentsOf: scriptURL)
|
try String(contentsOf: scriptURL)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Inits
|
// MARK: - Inits
|
||||||
|
|
||||||
/// Creates a migration with a specified version and script URL.
|
/// Creates a migration with the specified version and script URL.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - version: The version this migration corresponds to.
|
/// - version: The version this migration corresponds to.
|
||||||
/// - scriptURL: The file URL to the migration script.
|
/// - scriptURL: The URL of the script file to execute.
|
||||||
public init(version: Version, scriptURL: URL) {
|
public init(version: Version, scriptURL: URL) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.scriptURL = scriptURL
|
self.scriptURL = scriptURL
|
||||||
@@ -43,33 +54,25 @@ public struct Migration<Version: VersionRepresentable>: Hashable, Sendable {
|
|||||||
|
|
||||||
/// Creates a migration by locating a script resource in the specified bundle.
|
/// Creates a migration by locating a script resource in the specified bundle.
|
||||||
///
|
///
|
||||||
/// This initializer attempts to locate a script file in the provided bundle using the specified
|
/// Searches the given bundle for a script resource matching the provided name and optional file
|
||||||
/// resource `name` and optional `extension`. The `name` parameter may include or omit the file extension.
|
/// extension.
|
||||||
///
|
|
||||||
/// - If `name` includes an extension (e.g., `"001_init.sql"`), pass `extension` as `nil` or an empty string.
|
|
||||||
/// - If `name` omits the extension (e.g., `"001_init"`), specify the extension separately
|
|
||||||
/// (e.g., `"sql"`), or leave it `nil` if the file has no extension.
|
|
||||||
///
|
|
||||||
/// - Important: Passing a name that already includes the extension while also specifying a non-`nil`
|
|
||||||
/// `extension` may result in failure to locate the file.
|
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - version: The version this migration corresponds to.
|
/// - version: The version this migration corresponds to.
|
||||||
/// - name: The resource name of the script file. May include or omit the file extension.
|
/// - name: The resource name of the script file. Can include or omit its extension.
|
||||||
/// - extension: The file extension, if separated from the name. Defaults to `nil`.
|
/// - extension: The file extension, if separate from the name. Defaults to `nil`.
|
||||||
/// - bundle: The bundle in which to search for the resource. Defaults to `.main`.
|
/// - bundle: The bundle in which to look for the resource. Defaults to `.main`.
|
||||||
///
|
///
|
||||||
/// - Returns: A `Migration` if the resource file is found; otherwise, `nil`.
|
/// - Returns: A `Migration` instance if the resource is found; otherwise, `nil`.
|
||||||
public init?(
|
public init?(
|
||||||
version: Version,
|
version: Version,
|
||||||
byResource name: String,
|
byResource name: String,
|
||||||
extension: String? = nil,
|
extension: String? = nil,
|
||||||
in bundle: Bundle = .main
|
in bundle: Bundle = .main
|
||||||
) {
|
) {
|
||||||
guard let url = bundle.url(
|
guard let url = bundle.url(forResource: name, withExtension: `extension`) else {
|
||||||
forResource: name,
|
return nil
|
||||||
withExtension: `extension`
|
}
|
||||||
) else { return nil }
|
|
||||||
self.init(version: version, scriptURL: url)
|
self.init(version: version, scriptURL: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DataLiteC
|
|||||||
import DataLiteCore
|
import DataLiteCore
|
||||||
import DataRaft
|
import DataRaft
|
||||||
|
|
||||||
class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable {
|
class DatabaseServiceTests: ConnectionServiceKeyProvider, @unchecked Sendable {
|
||||||
private let keyOne = Connection.Key.rawKey(Data([
|
private let keyOne = Connection.Key.rawKey(Data([
|
||||||
0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0,
|
0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0,
|
||||||
0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a,
|
0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a,
|
||||||
@@ -54,13 +54,16 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable {
|
|||||||
try? FileManager.default.removeItem(at: fileURL)
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseService(keyFor service: any DatabaseServiceProtocol) throws -> Connection.Key {
|
func connectionService(keyFor service: any ConnectionServiceProtocol) throws -> Connection.Key {
|
||||||
currentKey
|
currentKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseService(shouldReconnect service: any DatabaseServiceProtocol) -> Bool {
|
func connectionService(shouldReconnect service: any ConnectionServiceProtocol) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func connectionService(_ service: any ConnectionServiceProtocol, didReceive error: any Error) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseServiceTests {
|
extension DatabaseServiceTests {
|
||||||
@@ -138,8 +141,8 @@ extension DatabaseServiceTests {
|
|||||||
path: fileURL.path,
|
path: fileURL.path,
|
||||||
options: [.readwrite]
|
options: [.readwrite]
|
||||||
)
|
)
|
||||||
try connection.apply(currentKey)
|
try connection.apply(currentKey, name: nil)
|
||||||
try connection.rekey(keyTwo)
|
try connection.rekey(keyTwo, name: nil)
|
||||||
currentKey = keyTwo
|
currentKey = keyTwo
|
||||||
|
|
||||||
try service.perform(in: .deferred) { connection in
|
try service.perform(in: .deferred) { connection in
|
||||||
@@ -166,9 +169,9 @@ extension DatabaseServiceTests {
|
|||||||
path: fileURL.path,
|
path: fileURL.path,
|
||||||
options: [.readwrite]
|
options: [.readwrite]
|
||||||
)
|
)
|
||||||
try connection.apply(currentKey)
|
try connection.apply(currentKey, name: nil)
|
||||||
try connection.rekey(keyTwo)
|
try connection.rekey(keyTwo, name: nil)
|
||||||
let error = Connection.Error(
|
let error = SQLiteError(
|
||||||
code: SQLITE_NOTADB,
|
code: SQLITE_NOTADB,
|
||||||
message: "file is not a database"
|
message: "file is not a database"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import DataLiteCore
|
|||||||
@testable import DataRaft
|
@testable import DataRaft
|
||||||
|
|
||||||
@Suite struct MigrationServiceTests {
|
@Suite struct MigrationServiceTests {
|
||||||
private typealias MigrationService = DataRaft.MigrationService<DatabaseService, VersionStorage>
|
private typealias MigrationService = DataRaft.MigrationService<VersionStorage>
|
||||||
private typealias MigrationError = DataRaft.MigrationError<MigrationService.Version>
|
private typealias MigrationError = DataRaft.MigrationError<MigrationService.Version>
|
||||||
|
|
||||||
private var connection: Connection!
|
private var connection: Connection!
|
||||||
@@ -12,7 +12,7 @@ import DataLiteCore
|
|||||||
init() throws {
|
init() throws {
|
||||||
let connection = try Connection(location: .inMemory, options: .readwrite)
|
let connection = try Connection(location: .inMemory, options: .readwrite)
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.migrationService = .init(service: .init(connection: connection), storage: .init())
|
self.migrationService = .init(connection: connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func addMigration() throws {
|
@Test func addMigration() throws {
|
||||||
@@ -68,7 +68,7 @@ import DataLiteCore
|
|||||||
@Test func migrateEmpty() async throws {
|
@Test func migrateEmpty() async throws {
|
||||||
let migration1 = Migration<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
|
let migration1 = Migration<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
|
||||||
let migration2 = Migration<Int32>(version: 2, byResource: "migration_2", extension: "sql", in: .module)!
|
let migration2 = Migration<Int32>(version: 2, byResource: "migration_2", extension: "sql", in: .module)!
|
||||||
let migration4 = Migration<Int32>(version: 4, byResource: "migration_4", extension: "sql", in: .module)!
|
let migration4 = Migration<Int32>(version: 4, byResource: "empty", extension: "sql", in: .module)!
|
||||||
|
|
||||||
try migrationService.add(migration1)
|
try migrationService.add(migration1)
|
||||||
try migrationService.add(migration2)
|
try migrationService.add(migration2)
|
||||||
@@ -91,11 +91,11 @@ private extension MigrationServiceTests {
|
|||||||
struct VersionStorage: DataRaft.VersionStorage {
|
struct VersionStorage: DataRaft.VersionStorage {
|
||||||
typealias Version = Int32
|
typealias Version = Int32
|
||||||
|
|
||||||
func getVersion(_ connection: Connection) throws -> Version {
|
func getVersion(_ connection: ConnectionProtocol) throws -> Version {
|
||||||
connection.userVersion
|
connection.userVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVersion(_ connection: Connection, _ version: Version) throws {
|
func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws {
|
||||||
connection.userVersion = version
|
connection.userVersion = version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
Tests/DataRaftTests/Resources/empty.sql
Normal file
1
Tests/DataRaftTests/Resources/empty.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
-- Empty Script
|
|
||||||
@@ -14,14 +14,13 @@ import Foundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func initFromBundle_success() throws {
|
@Test func initFromBundle_success() throws {
|
||||||
let bundle = Bundle.module // или другой, если тестовая ресурсная цель другая
|
|
||||||
let version = DummyVersion(rawValue: 2)
|
let version = DummyVersion(rawValue: 2)
|
||||||
|
|
||||||
let migration = Migration(
|
let migration = Migration(
|
||||||
version: version,
|
version: version,
|
||||||
byResource: "migration_1",
|
byResource: "migration_1",
|
||||||
extension: "sql",
|
extension: "sql",
|
||||||
in: bundle
|
in: .module
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(migration != nil)
|
#expect(migration != nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user