From 5bbb722b20b5d9099221c6b3785d51abd3fc05a0 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Fri, 7 Nov 2025 20:38:09 +0200 Subject: [PATCH] Refactoring --- Package.swift | 14 +- .../Aliases/VersionRepresentable.swift | 35 ++ .../DataRaft/Classes/ConnectionService.swift | 204 +++++++++ .../DataRaft/Classes/DatabaseService.swift | 426 ++++++++---------- .../DataRaft/Classes/MigrationService.swift | 246 ++++++---- .../Classes/ModelDatabaseService.swift | 138 ++++++ .../DataRaft/Classes/RowDatabaseService.swift | 115 ----- .../DataRaft/Classes/UserVersionStorage.swift | 75 +-- Sources/DataRaft/Enums/MigrationError.swift | 32 +- .../Extensions/Notification+UserInfoKey.swift | 33 ++ .../Extensions/NotificationCenter.swift | 9 + .../ConnectionServiceKeyProvider.swift | 45 ++ .../Protocols/ConnectionServiceProtocol.swift | 56 +++ .../DatabaseServiceKeyProvider.swift | 53 --- .../Protocols/DatabaseServiceProtocol.swift | 76 ++-- .../Protocols/MigrationServiceProtocol.swift | 58 ++- .../RowDatabaseServiceProtocol.swift | 17 - .../Protocols/VersionRepresentable.swift | 33 -- .../DataRaft/Protocols/VersionStorage.swift | 98 ++-- .../DataRaft/Structures/BitPackVersion.swift | 3 +- Sources/DataRaft/Structures/Migration.swift | 67 +-- .../Classes/DatabaseServiceTests.swift | 19 +- .../Classes/MigrationServiceTests.swift | 10 +- Tests/DataRaftTests/Resources/empty.sql | 1 + Tests/DataRaftTests/Resources/migration_4.sql | 1 - .../Structures/MigrationTests.swift | 3 +- 26 files changed, 1097 insertions(+), 770 deletions(-) create mode 100644 Sources/DataRaft/Aliases/VersionRepresentable.swift create mode 100644 Sources/DataRaft/Classes/ConnectionService.swift create mode 100644 Sources/DataRaft/Classes/ModelDatabaseService.swift delete mode 100644 Sources/DataRaft/Classes/RowDatabaseService.swift create mode 100644 Sources/DataRaft/Extensions/Notification+UserInfoKey.swift create mode 100644 Sources/DataRaft/Extensions/NotificationCenter.swift create mode 100644 Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift create mode 100644 Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift delete mode 100644 Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift delete mode 100644 Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift delete mode 100644 Sources/DataRaft/Protocols/VersionRepresentable.swift create mode 100644 Tests/DataRaftTests/Resources/empty.sql delete mode 100644 Tests/DataRaftTests/Resources/migration_4.sql diff --git a/Package.swift b/Package.swift index 8ee30ed..9995664 100644 --- a/Package.swift +++ b/Package.swift @@ -16,14 +16,8 @@ let package = Package( ) ], dependencies: [ - .package( - url: "https://github.com/angd-dev/data-lite-core.git", - revision: "5c6942bd0b9636b5ac3e550453c07aac843e8416" - ), - .package( - url: "https://github.com/angd-dev/data-lite-coder.git", - revision: "5aec6ea5784dd5bd098bfa98036fbdc362a8931c" - ), + .package(url: "https://github.com/angd-dev/data-lite-core.git", from: "1.0.0"), + .package(url: "https://github.com/angd-dev/data-lite-coder.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ @@ -38,10 +32,10 @@ let package = Package( name: "DataRaftTests", dependencies: ["DataRaft"], resources: [ + .copy("Resources/empty.sql"), .copy("Resources/migration_1.sql"), .copy("Resources/migration_2.sql"), - .copy("Resources/migration_3.sql"), - .copy("Resources/migration_4.sql") + .copy("Resources/migration_3.sql") ] ) ] diff --git a/Sources/DataRaft/Aliases/VersionRepresentable.swift b/Sources/DataRaft/Aliases/VersionRepresentable.swift new file mode 100644 index 0000000..071cd4e --- /dev/null +++ b/Sources/DataRaft/Aliases/VersionRepresentable.swift @@ -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 diff --git a/Sources/DataRaft/Classes/ConnectionService.swift b/Sources/DataRaft/Classes/ConnectionService.swift new file mode 100644 index 0000000..fb8708d --- /dev/null +++ b/Sources/DataRaft/Classes/ConnectionService.swift @@ -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() + + 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(_ closure: Perform) 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 + } + } +} diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 82b4462..492ba1b 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -1,313 +1,255 @@ import Foundation -import DataLiteC import DataLiteCore +import DataLiteC -/// Base service for working with a database. +/// A base database service handling transactions and event notifications. /// -/// `DatabaseService` provides a unified interface for performing operations using a database -/// connection, with built-in support for transactions, reconnection, and optional encryption -/// key management. +/// ## Overview /// -/// The service ensures thread-safe execution by serializing access to the connection through -/// an internal queue. This enables building modular and safe data access layers without -/// duplicating low-level logic. +/// `DatabaseService` provides a foundational layer for performing transactional database operations +/// within a thread-safe execution context. It automatically posts lifecycle notifications — such as +/// commit, rollback, and content changes — allowing observers to react to database updates in real +/// time. By default, it routes events through ``Foundation/NotificationCenter/database`` so that +/// clients can subscribe via a dedicated channel. This service is designed to be subclassed by +/// higher-level data managers that encapsulate domain logic while relying on consistent connection +/// and transaction handling. /// -/// The connection is established lazily on first use (e.g., within `perform`), not during -/// initialization. If a key provider is set, the key is applied as part of establishing or -/// restoring the connection. -/// -/// Below is an example of creating a service for managing notes: +/// ## Usage /// /// ```swift /// final class NoteService: DatabaseService { /// func insertNote(_ text: String) throws { -/// try perform { connection in -/// let stmt = try connection.prepare(sql: "INSERT INTO notes (text) VALUES (?)") +/// try perform(in: .deferred) { connection in +/// let sql = "INSERT INTO notes (text) VALUES (?)" +/// let stmt = try connection.prepare(sql: sql) /// try stmt.bind(text, at: 0) /// 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) -/// /// 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 /// /// ### Initializers /// -/// - ``ConnectionProvider`` -/// - ``ConnectionConfig`` -/// - ``init(provider:config:keyProvider:queue:)`` -/// - ``init(connection:config:keyProvider:queue:)`` +/// - ``ConnectionService/ConnectionProvider`` +/// - ``ConnectionService/ConnectionConfig`` +/// - ``init(provider:config:queue:center:)`` +/// - ``init(provider:config:queue:)`` /// -/// ### Key Management +/// ### Performing Operations /// -/// - ``DatabaseServiceKeyProvider`` -/// - ``keyProvider`` -/// -/// ### Database Operations -/// -/// - ``DatabaseServiceProtocol/Perform`` +/// - ``ConnectionServiceProtocol/Perform`` /// - ``perform(_:)`` /// - ``perform(in:closure:)`` -open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { - // MARK: - Types - - /// A closure that creates a new database connection. - /// - /// `ConnectionProvider` is used for deferred connection creation. - /// It allows encapsulating 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 -> Connection - - /// A closure used to configure a newly created connection. - /// - /// 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 - +/// +/// ### Connection Delegate +/// +/// - ``connection(_:didUpdate:)`` +/// - ``connectionWillCommit(_:)`` +/// - ``connectionDidRollback(_:)`` +/// +/// ### Notifications +/// +/// - ``databaseDidChange`` +/// - ``databaseWillCommit`` +/// - ``databaseDidRollback`` +/// - ``databaseDidPerform`` +open class DatabaseService: + ConnectionService, + DatabaseServiceProtocol, + ConnectionDelegate, + @unchecked Sendable +{ // MARK: - Properties - private let provider: ConnectionProvider - private let config: ConnectionConfig? - private let queue: DispatchQueue - private let queueKey = DispatchSpecificKey() + private let center: NotificationCenter - private var cachedConnection: Connection? - private var connection: Connection { - get throws { - guard let cachedConnection else { - let connection = try connect() - cachedConnection = connection - return connection - } - return cachedConnection - } - } - - /// Encryption key provider. + /// Notification posted after the database content changes. /// - /// 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? + /// Observers listen to this event to refresh cached data or update dependent components once + /// modifications are committed. The notification’s `userInfo` may include + /// ``Foundation/Notification/UserInfoKey/action`` describing the SQLite action. + public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange") + + /// Notification posted immediately before a transaction commits. + /// + /// Observers can perform validation or prepare for an upcoming state change while the + /// transaction is still in progress. + public static let databaseWillCommit = Notification.Name("DatabaseService.databaseWillCommit") + + /// Notification posted after a transaction rolls back. + /// + /// Observers use this event to revert in-memory state or reset caches that rely on pending + /// changes. + public static let databaseDidRollback = Notification.Name("DatabaseService.databaseDidRollback") + + /// Notification posted after any database operation completes, regardless of outcome. + /// + /// The service emits this event after finishing a `perform(_:)` block so observers can + /// synchronize state even when the operation is read-only or aborted. + /// + /// - Important: Confirm that the associated transaction was not rolled back before relying on + /// side effects. + public static let databaseDidPerform = Notification.Name("DatabaseService.databaseDidPerform") // MARK: - Inits - /// Creates a new database service. + /// Creates a database service that posts lifecycle events to the provided notification center. /// - /// Configures the internal serial queue for thread-safe access to the database. - /// The connection is **not** created during initialization. It is established - /// lazily on first use (for example, inside `perform`). - /// - /// The internal queue is always created with QoS `.utility`. If the `queue` - /// parameter is provided, it is used as the target queue for the internal one. - /// - /// If a `keyProvider` is set, the encryption key will be applied when the - /// connection is established or restored. + /// The underlying connection handling matches ``ConnectionService``; the connection is created + /// lazily and all work executes on the managed serial queue. /// /// - Parameters: - /// - provider: A closure that returns a new connection. - /// - config: An optional configuration closure called after the connection - /// is created (and after key application if present). - /// - keyProvider: An optional encryption key provider. - /// - queue: An optional target queue for the internal one. + /// - 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 serial queue. + /// - center: A notification center for posting database events. public init( provider: @escaping ConnectionProvider, 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 that posts lifecycle events to the shared database notification + /// center. + /// + /// The connection is established lazily on first access and all work executes on the internal + /// queue defined in ``ConnectionService``. + /// + /// - 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 serial queue. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, queue: DispatchQueue? = nil ) { - self.provider = provider - self.config = config - self.keyProvider = keyProvider - self.queue = .init(for: Self.self, qos: .utility) - self.queue.setSpecific(key: queueKey, value: ()) - if let queue = queue { - self.queue.setTarget(queue: queue) + self.center = .database + super.init(provider: provider, config: config, queue: queue) + } + + // MARK: - Performing Operations + + /// Executes a closure with a managed database connection and posts a completion notification. + /// + /// The override mirrors ``ConnectionService/perform(_:)`` for queue-confined execution while + /// ensuring ``DatabaseService/databaseDidPerform`` is delivered after the closure completes. + /// + /// - Parameter closure: The operation to execute using the open connection. + /// - Returns: The value returned by the closure. + /// - Throws: Errors thrown by the closure or underlying connection. + public override func perform(_ closure: Perform) throws -> T { + try super.perform { connection in + defer { center.post(name: Self.databaseDidPerform, 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 key will be applied when the connection is established. + /// The method begins the requested `TransactionType`, runs the closure, and commits the + /// 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: - /// - provider: An expression that creates a new connection. - /// - config: An optional configuration closure called after the connection - /// 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. + /// - transaction: The type of transaction to start (for example, `.deferred`). + /// - closure: The work to run while the transaction is active. /// - Returns: The value returned by the closure. - /// - Throws: An error if the connection cannot be created or if the closure throws. - final public func perform(_ closure: Perform) throws -> T { - try withConnection(closure) - } - - /// Executes a closure inside a transaction if the connection is in autocommit mode. + /// - Throws: Errors from the closure, transaction handling, or connection management. /// - /// If the connection is in autocommit mode, starts a new transaction of the specified - /// type, executes the closure, and commits changes on success. If the closure throws - /// an error, the transaction is rolled back. - /// - /// 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( + /// - 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). + public func perform( in transaction: TransactionType, closure: Perform ) throws -> T { - try withConnection { connection in - if connection.isAutocommit { - do { - try connection.beginTransaction(transaction) - let result = try closure(connection) - try connection.commitTransaction() - return result - } catch { + try perform { connection in + guard connection.isAutocommit else { + return try closure(connection) + } + + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + if !connection.isAutocommit { try connection.rollbackTransaction() - guard let error = error as? Connection.Error, - error.code == SQLITE_NOTADB, - shouldReconnect - else { throw error } - - try reconnect() - - return try withConnection { connection in - do { - try connection.beginTransaction(transaction) - let result = try closure(connection) - try connection.commitTransaction() - return result - } catch { + } + + guard + let error = error as? SQLiteError, + error.code == SQLITE_NOTADB, + setNeedsReconnect() + else { + throw error + } + + return try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + if !connection.isAutocommit { try connection.rollbackTransaction() - throw error } + throw error } } - } else { - return try closure(connection) } } } -} - -// MARK: - Private - -private extension DatabaseService { - var shouldReconnect: Bool { - keyProvider?.databaseService(shouldReconnect: self) ?? false + + // MARK: - ConnectionDelegate + + /// Posts ``DatabaseService/databaseDidChange`` when the database content updates. + /// + /// - Parameters: + /// - connection: The connection that performed the change. + /// - action: The SQLite action describing the modification. + public func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) { + let userInfo = [Notification.UserInfoKey.action: action] + center.post(name: Self.databaseDidChange, object: self, userInfo: userInfo) } - func withConnection(_ closure: Perform) throws -> T { - switch DispatchQueue.getSpecific(key: queueKey) { - case .none: try queue.asyncAndWait { try closure(connection) } - case .some: try closure(connection) - } + /// Posts ``DatabaseService/databaseWillCommit`` before a transaction commits. + /// + /// - Parameter connection: The connection preparing to commit. + public func connectionWillCommit(_ connection: any ConnectionProtocol) throws { + center.post(name: Self.databaseWillCommit, object: self) } - func reconnect() throws { - cachedConnection = try connect() + /// Posts ``DatabaseService/databaseDidRollback`` after a transaction rollback. + /// + /// - Parameter connection: The connection that rolled back. + public func connectionDidRollback(_ connection: any ConnectionProtocol) { + center.post(name: Self.databaseDidRollback, object: self) } - func connect() throws -> Connection { - let connection = try provider() - try applyKey(to: connection) - try config?(connection) + // MARK: - Internal Methods + + override func connect() throws -> any ConnectionProtocol { + let connection = try super.connect() + connection.add(delegate: self) 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 - } - } } diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift index 5c564da..840b06a 100644 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -1,135 +1,195 @@ import Foundation 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 -/// to update the database schema. Each migration runs only once, in version order, -/// based on the current schema version stored in the database. +/// ## Overview /// -/// The service is generic over: -/// - `Service`: a database service conforming to ``DatabaseServiceProtocol`` -/// - `Storage`: a version storage conforming to ``VersionStorage`` +/// This class manages migration registration and applies them sequentially to update the database +/// schema. Each migration corresponds to a specific version and runs only once, ensuring that +/// schema upgrades are applied in a consistent, deterministic way. /// -/// Migrations are identified by version and script URL. Both must be unique -/// across all registered migrations. +/// Migrations are executed within an exclusive transaction — if any step fails, the entire process +/// is rolled back, leaving the database unchanged. /// -/// Execution is performed inside a single `.exclusive` transaction, ensuring -/// that either all pending migrations are applied successfully or none are. -/// On error, the database state is rolled back to the original version. +/// `MigrationService` coordinates the migration process by: +/// - Managing a registry of unique migrations. +/// - 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 /// let connection = try Connection(location: .inMemory, options: .readwrite) -/// let storage = UserVersionStorage() -/// let service = MigrationService(service: connectionService, storage: storage) +/// let storage = UserVersionStorage() +/// 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.1", byResource: "v_1_0_1.sql")!) +/// try service.add(Migration(version: "1.0.0", byResource: "v1_0_0.sql")!) +/// try service.add(Migration(version: "1.0.1", byResource: "v1_0_1.sql")!) /// try service.migrate() /// ``` /// -/// ### Custom Versions and Storage +/// ## Topics /// -/// You can supply a custom `Version` type conforming to ``VersionRepresentable`` -/// and a `VersionStorage` implementation that determines how and where the -/// version is persisted (e.g., `PRAGMA user_version`, metadata table, etc.). +/// ### Initializers +/// +/// - ``init(provider:config:queue:storage:)`` +/// - ``init(provider:config:queue:)`` +/// +/// ### Migration Management +/// +/// - ``add(_:)`` +/// - ``migrate()`` public final class MigrationService< - Service: DatabaseServiceProtocol, Storage: VersionStorage >: + ConnectionService, MigrationServiceProtocol, @unchecked Sendable { - /// Schema version type used for migration ordering. + // MARK: - Typealiases + + /// The type representing schema version ordering. public typealias Version = Storage.Version - private let service: Service + // MARK: - Properties + private let storage: Storage - private var mutex = pthread_mutex_t() private var migrations = Set>() - /// Encryption key provider delegated to the underlying database service. - public weak var keyProvider: DatabaseServiceKeyProvider? { - get { service.keyProvider } - set { service.keyProvider = newValue } - } + #if os(Windows) + private var mutex = SRWLOCK() + #else + 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: - /// - service: Database service used to execute migrations. - /// - storage: Version storage for reading and writing schema version. + /// - 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. + /// - storage: The version storage responsible for reading and writing schema version data. public init( - service: Service, + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, storage: Storage ) { - self.service = service 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 { - 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(_ closure: Perform) throws -> T { + fatalError("Direct perform is not supported for MigrationService.") + } + + // MARK: - Migration Management + /// Registers a new migration, ensuring version and script URL uniqueness. /// /// - Parameter migration: The migration to register. - /// - Throws: ``MigrationError/duplicateMigration(_:)`` if the migration's - /// version or script URL is already registered. + /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version or + /// script URL is already registered. public func add(_ migration: Migration) throws(MigrationError) { - pthread_mutex_lock(&mutex) - defer { pthread_mutex_unlock(&mutex) } - guard !migrations.contains(where: { - $0.version == migration.version - || $0.scriptURL == migration.scriptURL - }) else { + #if os(Windows) + AcquireSRWLockExclusive(&mutex) + defer { ReleaseSRWLockExclusive(&mutex) } + #else + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } + #endif + + guard + !migrations.contains(where: { + $0.version == migration.version + || $0.scriptURL == migration.scriptURL + }) + else { throw .duplicateMigration(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 - /// which migrations have a higher version. The selected migrations are sorted in - /// 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. + /// The service retrieves the current version from ``VersionStorage``, selects migrations with + /// higher versions, sorts them, and executes their scripts inside an exclusive transaction. /// - /// If a script is empty or execution fails, the process aborts and the transaction - /// is rolled back, leaving the database unchanged. - /// - /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a script is empty. - /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if execution or version - /// update fails. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration fails to execute or the + /// version update cannot be persisted. public func migrate() throws(MigrationError) { - pthread_mutex_lock(&mutex) - defer { pthread_mutex_unlock(&mutex) } + #if os(Windows) + AcquireSRWLockExclusive(&mutex) + defer { ReleaseSRWLockExclusive(&mutex) } + #else + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } + #endif + do { - try service.perform(in: .exclusive) { connection in - 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) + try super.perform { connection in + do { + try connection.beginTransaction(.exclusive) + try migrate(with: connection) + try connection.commitTransaction() + } catch { + if !connection.isAutocommit { + try connection.rollbackTransaction() } - do { - try connection.execute(sql: script) - } catch { - throw MigrationError.migrationFailed(migration, error) - } - } - - if let version = migrations.last?.version { - try storage.setVersion(connection, version) + throw error } } } catch let error as MigrationError { @@ -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) + } + } +} diff --git a/Sources/DataRaft/Classes/ModelDatabaseService.swift b/Sources/DataRaft/Classes/ModelDatabaseService.swift new file mode 100644 index 0000000..cb1b441 --- /dev/null +++ b/Sources/DataRaft/Classes/ModelDatabaseService.swift @@ -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 `.database`. + /// - 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 = .database, + 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 + ) + } +} diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift deleted file mode 100644 index ed4a694..0000000 --- a/Sources/DataRaft/Classes/RowDatabaseService.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift index 1fdad7c..5b051f9 100644 --- a/Sources/DataRaft/Classes/UserVersionStorage.swift +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -1,45 +1,57 @@ import Foundation 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 -/// in the SQLite `PRAGMA user_version` field. It provides a lightweight, -/// type-safe way to persist versioning data in a database. +/// ## Overview /// -/// The generic `Version` type must conform to both ``VersionRepresentable`` -/// and `RawRepresentable`, where `RawValue == UInt32`. This allows -/// converting between stored integer values and semantic version types -/// defined by the application. +/// `UserVersionStorage` provides a lightweight, type-safe implementation of ``VersionStorage`` that +/// stores version data using the SQLite `PRAGMA user_version` mechanism. This approach is simple, +/// efficient, and requires no additional tables. +/// +/// 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< Version: VersionRepresentable & RawRepresentable >: 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 { - /// 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) } // MARK: - Inits - /// Creates a new user version storage instance. + /// Creates a new instance of user version storage. 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 - /// decode it into a valid `Version` value. If the stored value is not - /// recognized, it throws an error. + /// Reads the `PRAGMA user_version` value and attempts to decode it into a valid ``Version``. + /// If decoding fails, this method throws an error. /// - /// - Parameter connection: The database connection. - /// - Returns: A decoded version value of type `Version`. - /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value - /// cannot be mapped to a valid `Version` instance. - public func getVersion( - _ connection: Connection - ) throws -> Version { + /// - Parameter connection: The active database connection. + /// - Returns: The decoded version value. + /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value cannot + /// be mapped to a valid version case. + public func getVersion(_ connection: ConnectionProtocol) throws -> Version { let raw = UInt32(bitPattern: connection.userVersion) guard let version = Version(rawValue: raw) else { throw Error.invalidStoredVersion(raw) @@ -47,20 +59,15 @@ public final class UserVersionStorage< 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 - /// with the raw `UInt32` value of the provided `Version`. + /// Updates the SQLite `PRAGMA user_version` value with the raw `UInt32` representation of the + /// provided ``Version``. /// /// - Parameters: - /// - connection: The database connection. + /// - connection: The active database connection. /// - version: The version to store. - public func setVersion( - _ connection: Connection, - _ version: Version - ) throws { - connection.userVersion = .init( - bitPattern: version.rawValue - ) + public func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { + connection.userVersion = .init(bitPattern: version.rawValue) } } diff --git a/Sources/DataRaft/Enums/MigrationError.swift b/Sources/DataRaft/Enums/MigrationError.swift index 6fcad25..21f1b09 100644 --- a/Sources/DataRaft/Enums/MigrationError.swift +++ b/Sources/DataRaft/Enums/MigrationError.swift @@ -1,14 +1,34 @@ import Foundation 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: 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) - /// Migration execution failed, with optional reference to the failed migration. - case migrationFailed(Migration?, Error) - - /// The migration script is empty. + /// Indicates that the migration script is empty. + /// + /// - Parameter migration: The migration whose script is empty. case emptyMigrationScript(Migration) + + /// Indicates that migration execution failed. + /// + /// - Parameters: + /// - migration: The migration that failed, if available. + /// - error: The underlying error that caused the failure. + case migrationFailed(Migration?, Error) } diff --git a/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift b/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift new file mode 100644 index 0000000..f682447 --- /dev/null +++ b/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift @@ -0,0 +1,33 @@ +import Foundation + +extension Notification { + /// A strongly typed key used to access values in a notification’s user info dictionary. + /// + /// ## Overview + /// + /// `UserInfoKey` provides type safety when working with `Notification.userInfo`, replacing raw + /// string literals with well-defined constants. This helps prevent typos and improves + /// discoverability in database- or system-related notifications. + /// + /// ## Topics + /// + /// ### Keys + /// + /// - ``action`` + public struct UserInfoKey: RawRepresentable, Hashable, Sendable { + /// The raw string value of the key. + public let rawValue: String + + /// The key used to store the action associated with a notification. + public static let action = Self(rawValue: "action") + + /// Creates a user info key from the provided raw string value. + /// + /// Returns `nil` if the raw value is invalid. + /// + /// - Parameter rawValue: The raw string value of the key. + public init?(rawValue: String) { + self.rawValue = rawValue + } + } +} diff --git a/Sources/DataRaft/Extensions/NotificationCenter.swift b/Sources/DataRaft/Extensions/NotificationCenter.swift new file mode 100644 index 0000000..6f5a0a2 --- /dev/null +++ b/Sources/DataRaft/Extensions/NotificationCenter.swift @@ -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 database = NotificationCenter() +} diff --git a/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift b/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift new file mode 100644 index 0000000..2688b9c --- /dev/null +++ b/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift @@ -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) +} diff --git a/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift b/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift new file mode 100644 index 0000000..f69774b --- /dev/null +++ b/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift @@ -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 = (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(_ closure: Perform) throws -> T +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift deleted file mode 100644 index e59fc0c..0000000 --- a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift +++ /dev/null @@ -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) {} -} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift index 6066927..21fc8bc 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -1,67 +1,45 @@ import Foundation 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 -/// 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. +/// ## Overview /// -/// This enables building safe and extensible service layers on top of -/// a database. +/// This type builds on ``ConnectionServiceProtocol`` by adding the ability to execute closures +/// within explicit transactions. Conforming types manage transaction boundaries and ensure that all +/// operations within a transaction are committed or rolled back consistently. /// /// ## Topics /// -/// ### Key Management +/// ### Performing Operations /// -/// - ``DatabaseServiceKeyProvider`` -/// - ``keyProvider`` -/// -/// ### Database Operations -/// -/// - ``Perform`` -/// - ``perform(_:)`` +/// - ``ConnectionServiceProtocol/Perform`` /// - ``perform(in:closure:)`` -public protocol DatabaseServiceProtocol: AnyObject, Sendable { - /// A closure executed with an active database connection. +public protocol DatabaseServiceProtocol: ConnectionServiceProtocol { + /// Executes a closure inside a transaction if the connection is in autocommit mode. /// - /// Used by the service to safely provide access to `Connection` - /// within the appropriate execution context. + /// If the connection operates in autocommit mode, this method starts a new transaction of the + /// 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. - /// - Returns: The value returned by the closure. - /// - Throws: An error if the closure execution fails. - typealias Perform = (Connection) throws -> T - - /// The encryption key provider for the database service. + /// Implementations may attempt to re-establish the connection and reapply the encryption key if + /// an error indicates a lost or invalid database state (for example, `SQLiteError` with code + /// `SQLITE_NOTADB`). In such cases, the service can retry the transaction block once after a + /// successful reconnection. If reconnection fails or is disallowed by the key provider, the + /// original error is propagated. /// - /// Enables external management of encryption keys. - /// When set, the service can request a key when establishing or - /// 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(_ closure: Perform) 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. + /// If a transaction is already active, the closure is executed directly without starting a new + /// transaction. /// /// - Parameters: - /// - transaction: The type of transaction to begin. - /// - closure: The closure that accepts a connection. + /// - 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: 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(in transaction: TransactionType, closure: Perform) throws -> T } diff --git a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift index f1cde72..93cbfba 100644 --- a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -1,48 +1,58 @@ 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 -/// encryption keys (if required), and executing pending migrations in -/// ascending version order. +/// ## Overview /// -/// Migrations ensure that the database schema evolves consistently across -/// application versions without requiring manual intervention. +/// Conforming types are responsible for registering migration steps, applying encryption keys +/// (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 { - /// Type representing the schema version used for migrations. + /// The type representing a schema version used for migrations. associatedtype Version: VersionRepresentable - /// Encryption key provider for the database service. - var keyProvider: DatabaseServiceKeyProvider? { get set } + /// The provider responsible for supplying encryption keys to the service. + var keyProvider: ConnectionServiceKeyProvider? { get set } /// Registers a migration to be executed by the service. /// /// - Parameter migration: The migration to register. - /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with - /// the same version or script URL is already registered. + /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version + /// or script URL is already registered. func add(_ migration: Migration) throws(MigrationError) /// Executes all pending migrations in ascending version order. /// - /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration - /// script is empty. - /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution - /// or version update fails. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute + /// or update the stored version. func migrate() throws(MigrationError) } -@available(iOS 13.0, *) -@available(macOS 10.15, *) +@available(iOS 13.0, macOS 10.15, *) 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 - /// on a background task with `.utility` priority. + /// Performs the same logic as ``migrate()``, but runs asynchronously on a background task with + /// `.utility` priority. /// - /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration - /// script is empty. - /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution - /// or version update fails. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute + /// or update the stored version. func migrate() async throws { try await Task(priority: .utility) { try self.migrate() diff --git a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift deleted file mode 100644 index 8422ccf..0000000 --- a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift +++ /dev/null @@ -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 } -} diff --git a/Sources/DataRaft/Protocols/VersionRepresentable.swift b/Sources/DataRaft/Protocols/VersionRepresentable.swift deleted file mode 100644 index 8a276c8..0000000 --- a/Sources/DataRaft/Protocols/VersionRepresentable.swift +++ /dev/null @@ -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 diff --git a/Sources/DataRaft/Protocols/VersionStorage.swift b/Sources/DataRaft/Protocols/VersionStorage.swift index 1bcfdd5..8761e62 100644 --- a/Sources/DataRaft/Protocols/VersionStorage.swift +++ b/Sources/DataRaft/Protocols/VersionStorage.swift @@ -1,36 +1,33 @@ import Foundation 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 -/// the way the version is stored. It enables flexible implementations -/// that can store version values in different forms and places. +/// ## Overview /// -/// The associated `Version` type determines how the version is represented -/// (e.g. as an integer, a semantic string, or a structured object), while the -/// conforming type defines how that version is persisted. +/// This protocol separates the concept of version representation from its persistence mechanism, +/// allowing flexible implementations that store version values in different formats or locations. /// -/// Use this protocol to implement custom strategies for version tracking: -/// - Store an integer version in SQLite's `user_version` field. +/// The associated ``Version`` type specifies how the version is represented (for example, as an +/// 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 structured data in a JSON column. /// -/// To define your own versioning mechanism, implement `VersionStorage` -/// and choose a `Version` type that conforms to ``VersionRepresentable``. -/// -/// 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`. +/// The example below shows an implementation that stores the version string in a `schema_version` +/// table: /// /// ```swift /// final class StringVersionStorage: VersionStorage { /// typealias Version = String /// -/// func prepare(_ connection: Connection) throws { -/// let script: SQLScript = """ +/// func prepare(_ connection: ConnectionProtocol) throws { +/// let script = """ /// CREATE TABLE IF NOT EXISTS schema_version ( /// version TEXT NOT NULL /// ); @@ -42,7 +39,7 @@ import DataLiteCore /// 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 stmt = try connection.prepare(sql: query) /// guard try stmt.step(), let value: Version = stmt.columnValue(at: 0) else { @@ -51,7 +48,7 @@ import DataLiteCore /// return value /// } /// -/// func setVersion(_ connection: Connection, _ version: Version) throws { +/// func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { /// let query = "UPDATE schema_version SET version = ?" /// let stmt = try connection.prepare(sql: query) /// 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 /// /// ### Associated Types @@ -83,58 +69,54 @@ import DataLiteCore /// - ``getVersion(_:)`` /// - ``setVersion(_:_:)`` public protocol VersionStorage { - /// A type representing the database schema version. + /// The type representing the database schema version. associatedtype Version: VersionRepresentable + /// Creates a new instance of the version storage. + init() + /// Prepares the storage mechanism for tracking the schema version. /// - /// This method is called before any version operations. Use it to create required tables - /// or metadata structures needed for version management. + /// Called before any version operations. Use this method to create required tables or metadata + /// structures for version management. /// - /// - Important: This method is executed within an active migration transaction. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameter connection: The database connection used for schema preparation. /// - 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. /// - /// 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. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameter connection: The database connection used to fetch the version. /// - Returns: The version currently stored in the database. /// - 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. /// - /// This method is called at the end of the migration process to persist - /// the final schema version after all migration steps have completed successfully. + /// Called at the end of the migration process to persist the final schema version after all + /// migration steps complete successfully. /// - /// - Important: This method is executed within an active migration transaction. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameters: /// - connection: The database connection used to write the version. /// - version: The version to store. /// - Throws: An error if writing fails. - func setVersion(_ connection: Connection, _ version: Version) throws + func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws } public extension VersionStorage { - /// A default implementation that performs no preparation. - /// - /// 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 {} + func prepare(_ connection: ConnectionProtocol) throws {} } diff --git a/Sources/DataRaft/Structures/BitPackVersion.swift b/Sources/DataRaft/Structures/BitPackVersion.swift index f74add6..5b06d9a 100644 --- a/Sources/DataRaft/Structures/BitPackVersion.swift +++ b/Sources/DataRaft/Structures/BitPackVersion.swift @@ -109,8 +109,7 @@ public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStri // MARK: - ExpressibleByStringLiteral -@available(iOS 16.0, *) -@available(macOS 13.0, *) +@available(iOS 16.0, macOS 13.0, *) extension BitPackVersion: ExpressibleByStringLiteral { /// An error related to parsing a version string. public enum ParseError: Swift.Error { diff --git a/Sources/DataRaft/Structures/Migration.swift b/Sources/DataRaft/Structures/Migration.swift index 12d40da..ef5b73a 100644 --- a/Sources/DataRaft/Structures/Migration.swift +++ b/Sources/DataRaft/Structures/Migration.swift @@ -1,41 +1,52 @@ 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 -/// version to which this script corresponds. The script is expected to be bundled with the application. +/// ## Overview /// -/// You can initialize a migration directly with a URL to the script, or load it from a resource -/// embedded in a bundle. +/// Each migration links a version identifier with a script file that modifies the database schema. +/// 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: Hashable, Sendable { // MARK: - Properties /// The version associated with this migration step. 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 - /// 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 - /// `SQLScript` instance. Use this to access and execute the migration's SQL commands. + /// Reads the contents of the file at ``scriptURL`` and trims surrounding whitespace and + /// newlines. /// - /// - Throws: An error if the script file cannot be read or is invalid. - public var script: SQLScript { + /// - Throws: An error if the script file cannot be read. + public var script: String { get throws { - try SQLScript(contentsOf: scriptURL) + try String(contentsOf: scriptURL) + .trimmingCharacters(in: .whitespacesAndNewlines) } } // MARK: - Inits - /// Creates a migration with a specified version and script URL. + /// Creates a migration with the specified version and script URL. /// /// - Parameters: /// - 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) { self.version = version self.scriptURL = scriptURL @@ -43,33 +54,25 @@ public struct Migration: Hashable, Sendable { /// 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 - /// resource `name` and optional `extension`. The `name` parameter may include or omit the file 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. + /// Searches the given bundle for a script resource matching the provided name and optional file + /// extension. /// /// - Parameters: /// - version: The version this migration corresponds to. - /// - name: The resource name of the script file. May include or omit the file extension. - /// - extension: The file extension, if separated from the name. Defaults to `nil`. - /// - bundle: The bundle in which to search for the resource. Defaults to `.main`. + /// - name: The resource name of the script file. Can include or omit its extension. + /// - extension: The file extension, if separate from the name. Defaults to `nil`. + /// - 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?( version: Version, byResource name: String, extension: String? = nil, in bundle: Bundle = .main ) { - guard let url = bundle.url( - forResource: name, - withExtension: `extension` - ) else { return nil } + guard let url = bundle.url(forResource: name, withExtension: `extension`) else { + return nil + } self.init(version: version, scriptURL: url) } } diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift index 8af5157..6b605e2 100644 --- a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -4,7 +4,7 @@ import DataLiteC import DataLiteCore import DataRaft -class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { +class DatabaseServiceTests: ConnectionServiceKeyProvider, @unchecked Sendable { private let keyOne = Connection.Key.rawKey(Data([ 0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0, 0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a, @@ -54,13 +54,16 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { 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 } - func databaseService(shouldReconnect service: any DatabaseServiceProtocol) -> Bool { + func connectionService(shouldReconnect service: any ConnectionServiceProtocol) -> Bool { true } + + func connectionService(_ service: any ConnectionServiceProtocol, didReceive error: any Error) { + } } extension DatabaseServiceTests { @@ -138,8 +141,8 @@ extension DatabaseServiceTests { path: fileURL.path, options: [.readwrite] ) - try connection.apply(currentKey) - try connection.rekey(keyTwo) + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) currentKey = keyTwo try service.perform(in: .deferred) { connection in @@ -166,9 +169,9 @@ extension DatabaseServiceTests { path: fileURL.path, options: [.readwrite] ) - try connection.apply(currentKey) - try connection.rekey(keyTwo) - let error = Connection.Error( + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) + let error = SQLiteError( code: SQLITE_NOTADB, message: "file is not a database" ) diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift index d4095a2..84e7849 100644 --- a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -3,7 +3,7 @@ import DataLiteCore @testable import DataRaft @Suite struct MigrationServiceTests { - private typealias MigrationService = DataRaft.MigrationService + private typealias MigrationService = DataRaft.MigrationService private typealias MigrationError = DataRaft.MigrationError private var connection: Connection! @@ -12,7 +12,7 @@ import DataLiteCore init() throws { let connection = try Connection(location: .inMemory, options: .readwrite) self.connection = connection - self.migrationService = .init(service: .init(connection: connection), storage: .init()) + self.migrationService = .init(connection: connection) } @Test func addMigration() throws { @@ -68,7 +68,7 @@ import DataLiteCore @Test func migrateEmpty() async throws { let migration1 = Migration(version: 1, byResource: "migration_1", extension: "sql", in: .module)! let migration2 = Migration(version: 2, byResource: "migration_2", extension: "sql", in: .module)! - let migration4 = Migration(version: 4, byResource: "migration_4", extension: "sql", in: .module)! + let migration4 = Migration(version: 4, byResource: "empty", extension: "sql", in: .module)! try migrationService.add(migration1) try migrationService.add(migration2) @@ -91,11 +91,11 @@ private extension MigrationServiceTests { struct VersionStorage: DataRaft.VersionStorage { typealias Version = Int32 - func getVersion(_ connection: Connection) throws -> Version { + func getVersion(_ connection: ConnectionProtocol) throws -> Version { connection.userVersion } - func setVersion(_ connection: Connection, _ version: Version) throws { + func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { connection.userVersion = version } } diff --git a/Tests/DataRaftTests/Resources/empty.sql b/Tests/DataRaftTests/Resources/empty.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Tests/DataRaftTests/Resources/empty.sql @@ -0,0 +1 @@ + diff --git a/Tests/DataRaftTests/Resources/migration_4.sql b/Tests/DataRaftTests/Resources/migration_4.sql deleted file mode 100644 index af447e5..0000000 --- a/Tests/DataRaftTests/Resources/migration_4.sql +++ /dev/null @@ -1 +0,0 @@ --- Empty Script diff --git a/Tests/DataRaftTests/Structures/MigrationTests.swift b/Tests/DataRaftTests/Structures/MigrationTests.swift index 17d113b..b4541ae 100644 --- a/Tests/DataRaftTests/Structures/MigrationTests.swift +++ b/Tests/DataRaftTests/Structures/MigrationTests.swift @@ -14,14 +14,13 @@ import Foundation } @Test func initFromBundle_success() throws { - let bundle = Bundle.module // или другой, если тестовая ресурсная цель другая let version = DummyVersion(rawValue: 2) let migration = Migration( version: version, byResource: "migration_1", extension: "sql", - in: bundle + in: .module ) #expect(migration != nil)