diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b9ebf06 --- /dev/null +++ b/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "DataRaft", + platforms: [ + .macOS(.v10_14), + .iOS(.v12) + ], + products: [ + .library( + name: "DataRaft", + targets: ["DataRaft"] + ) + ], + dependencies: [ + .package(url: "https://github.com/angd-dev/data-lite-core.git", .upToNextMinor(from: "1.1.0")), + .package(url: "https://github.com/angd-dev/data-lite-coder.git", .upToNextMinor(from: "1.0.0")), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ], + targets: [ + .target( + name: "DataRaft", + dependencies: [ + .product(name: "DataLiteCore", package: "data-lite-core"), + .product(name: "DataLiteCoder", package: "data-lite-coder") + ] + ), + .testTarget( + name: "DataRaftTests", + dependencies: ["DataRaft"], + resources: [ + .copy("Resources/empty.sql"), + .copy("Resources/migration_1.sql"), + .copy("Resources/migration_2.sql"), + .copy("Resources/migration_3.sql") + ] + ) + ] +) diff --git a/README.md b/README.md index e69de29..11ddbce 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,62 @@ +# DataRaft + +DataRaft is a minimalistic Swift library for safe, predictable, and concurrent SQLite access. + +## Overview + +DataRaft provides a lightweight, high-level infrastructure for working with SQLite in Swift. It ensures thread-safe database access, streamlined transaction management, and a flexible migration system—without abstracting away SQL or imposing an ORM. + +Built on top of [DataLiteCore](https://github.com/angd-dev/data-lite-core) (a lightweight Swift SQLite wrapper) and [DataLiteCoder](https://github.com/angd-dev/data-lite-coder) (for type-safe encoding and decoding), DataRaft is designed for real-world applications where control, safety, and reliability are essential. + +The core philosophy behind DataRaft is to let developers retain full access to SQL while providing a simple and robust foundation for building database-powered applications. + +## Requirements + +- **Swift**: 6.0 or later +- **Platforms**: macOS 10.14+, iOS 12.0+, Linux + +## Installation + +To add DataRaft to your project, use Swift Package Manager (SPM). + +> **Important:** The API of `DataRaft` is currently unstable and may change without notice. It is **strongly recommended** to pin the dependency to a specific commit to ensure compatibility and avoid unexpected breakage when the API evolves. + +### Adding to an Xcode Project + +1. Open your project in Xcode. +2. Navigate to the `File` menu and select `Add Package Dependencies`. +3. Enter the repository URL: `https://github.com/angd-dev/data-raft.git` +4. Choose the version to install. +5. Add the library to your target module. + +### Adding to Package.swift + +If you are using Swift Package Manager with a `Package.swift` file, add the dependency like this: + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "YourProject", + dependencies: [ + .package(url: "https://github.com/angd-dev/data-raft.git", branch: "develop") + ], + targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "DataRaft", package: "data-raft") + ] + ) + ] +) +``` + +## Additional Resources + +For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=data-raft&version=develop). You can also explore related projects like [DataLiteCore](https://github.com/angd-dev/data-lite-core) and [DataLiteCoder](https://github.com/angd-dev/data-lite-coder). + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. 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 new file mode 100644 index 0000000..79686fa --- /dev/null +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -0,0 +1,261 @@ +import Foundation +import DataLiteCore +import DataLiteC + +/// A base database service that handles transactions and posts change notifications. +/// +/// ## Overview +/// +/// `DatabaseService` provides a lightweight transactional layer for performing database operations +/// within a thread-safe execution context. It automatically detects modifications to the database +/// and posts a ``databaseDidChange`` notification database updates, allowing observers to react to +/// updates. +/// +/// This class is intended to be subclassed by higher-level data managers that encapsulate domain +/// logic while relying on consistent connection and transaction handling. +/// +/// ## Usage +/// +/// ```swift +/// final class NoteService: DatabaseService { +/// func insertNote(_ text: String) throws { +/// 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() +/// } +/// } +/// } +/// +/// let connection = try Connection(location: .inMemory, options: []) +/// let service = NoteService(connection: connection) +/// try service.insertNote("Hello, world!") +/// ``` +/// +/// ## Topics +/// +/// ### Initializers +/// +/// - ``ConnectionService/ConnectionProvider`` +/// - ``ConnectionService/ConnectionConfig`` +/// - ``init(provider:config:queue:center:)`` +/// - ``init(provider:config:queue:)`` +/// +/// ### Performing Operations +/// +/// - ``ConnectionServiceProtocol/Perform`` +/// - ``perform(_:)`` +/// - ``perform(in:closure:)`` +/// +/// ### Connection Delegate +/// +/// - ``connection(_:didUpdate:)`` +/// - ``connectionWillCommit(_:)`` +/// - ``connectionDidRollback(_:)`` +/// +/// ### Notifications +/// +/// - ``databaseDidChange`` +open class DatabaseService: + ConnectionService, + DatabaseServiceProtocol, + ConnectionDelegate, + @unchecked Sendable +{ + // MARK: - Properties + + private let center: NotificationCenter? + + /// Notification posted after the database content changes with this service. + public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange") + + // MARK: - Inits + + /// Creates a database service with a specified notification center. + /// + /// Configures an internal serial queue for thread-safe access to the database. The connection + /// itself is not created during initialization — it is established lazily on first use (for + /// example, inside ``perform(_:)``). + /// + /// The 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. + /// - center: An optional notification center used to post database change notifications. + public init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, + center: NotificationCenter? + ) { + self.center = center + super.init(provider: provider, config: config, queue: queue) + } + + /// Creates a database service using the default database notification center. + /// + /// Configures an internal serial queue for thread-safe access to the database. The connection + /// itself is not created during initialization — it is established lazily on first use (for + /// example, inside ``perform(_:)``). + /// + /// The service posts change notifications through ``Foundation/NotificationCenter/databaseCenter``, + /// which provides a shared channel for observing database events across the application. + /// + /// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the + /// target of the internal queue. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established and + /// the encryption key is applied. + /// - queue: An optional target queue for the internal one. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.center = .databaseCenter + super.init(provider: provider, config: config, queue: queue) + } + + // 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. + /// + /// After the closure completes, if the database content has changed, the service posts a + /// ``databaseDidChange`` notification through its configured notification center. + /// + /// - Parameter closure: The operation to perform using the connection. + /// - Returns: The result produced by the closure. + /// - Throws: An error thrown by the closure or the connection. + public override func perform(_ closure: Perform) throws -> T { + try super.perform { connection in + let changes = connection.totalChanges + defer { + if changes != connection.totalChanges { + center?.post(name: Self.databaseDidChange, object: self) + } + } + return try closure(connection) + } + } + + /// Executes a closure inside a transaction when the connection operates in autocommit mode. + /// + /// 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: + /// - 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: Errors from the closure, transaction handling, or connection management. + /// + /// - 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 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? 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 + } + } + } + } + } + + // MARK: - ConnectionDelegate + + /// Handles database updates reported by the active connection. + /// + /// Called after an SQL statement modifies the database content. Subclasses can override this + /// method to observe specific actions (for example, inserts, updates, or deletes). + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. + /// + /// - Parameters: + /// - connection: The connection that performed the update. + /// - action: The SQLite action describing the change. + open func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) { + } + + /// Called immediately before the connection commits a transaction. + /// + /// Subclasses can override this method to perform validation or consistency checks prior to + /// committing. Throwing an error cancels the commit and triggers a rollback. + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. + /// + /// - Parameter connection: The connection preparing to commit. + /// - Throws: An error to cancel the commit and roll back the transaction. + open func connectionWillCommit(_ connection: any ConnectionProtocol) throws { + } + + /// Called after the connection rolls back a transaction. + /// + /// Subclasses can override this method to handle cleanup or recovery logic following a + /// rollback. + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. + /// + /// - Parameter connection: The connection that rolled back the transaction. + open func connectionDidRollback(_ connection: any ConnectionProtocol) { + } + + // MARK: - Internal Methods + + override func connect() throws -> any ConnectionProtocol { + let connection = try super.connect() + connection.add(delegate: self) + return connection + } +} diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift new file mode 100644 index 0000000..840b06a --- /dev/null +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -0,0 +1,229 @@ +import Foundation +import DataLiteCore + +#if os(Windows) + import WinSDK +#endif + +/// A service that executes ordered database schema migrations. +/// +/// ## Overview +/// +/// 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 executed within an exclusive transaction — if any step fails, the entire process +/// is rolled back, leaving the database unchanged. +/// +/// `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. +/// +/// 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(provider: { connection }, storage: storage) +/// +/// 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() +/// ``` +/// +/// ## Topics +/// +/// ### Initializers +/// +/// - ``init(provider:config:queue:storage:)`` +/// - ``init(provider:config:queue:)`` +/// +/// ### Migration Management +/// +/// - ``add(_:)`` +/// - ``migrate()`` +public final class MigrationService< + Storage: VersionStorage +>: + ConnectionService, + MigrationServiceProtocol, + @unchecked Sendable +{ + // MARK: - Typealiases + + /// The type representing schema version ordering. + public typealias Version = Storage.Version + + // MARK: - Properties + + private let storage: Storage + private var migrations = Set>() + + #if os(Windows) + private var mutex = SRWLOCK() + #else + private var mutex = pthread_mutex_t() + #endif + + // MARK: - Inits + + /// Creates a migration service with a specified connection configuration and 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. + /// - storage: The version storage responsible for reading and writing schema version data. + public init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, + storage: Storage + ) { + self.storage = storage + 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 { + #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 a migration with the same version or + /// script URL is already registered. + public func add(_ migration: Migration) throws(MigrationError) { + #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 in ascending version order. + /// + /// The service retrieves the current version from ``VersionStorage``, selects migrations with + /// higher versions, sorts them, and executes their scripts inside an exclusive transaction. + /// + /// - 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) { + #if os(Windows) + AcquireSRWLockExclusive(&mutex) + defer { ReleaseSRWLockExclusive(&mutex) } + #else + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } + #endif + + do { + try super.perform { connection in + do { + try connection.beginTransaction(.exclusive) + try migrate(with: connection) + try connection.commitTransaction() + } catch { + if !connection.isAutocommit { + try connection.rollbackTransaction() + } + throw error + } + } + } catch let error as MigrationError { + throw error + } catch { + throw .migrationFailed(nil, error) + } + } +} + +// 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..7f0599e --- /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 `.databaseCenter`. + /// - encoder: The encoder for converting models into SQLite rows. + /// - decoder: The decoder for converting rows back into model instances. + public init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, + center: NotificationCenter = .databaseCenter, + encoder: RowEncoder, + decoder: RowDecoder + ) { + self.encoder = encoder + self.decoder = decoder + super.init( + provider: provider, + config: config, + queue: queue, + center: center + ) + } + + /// Creates a model-aware database service using default encoder and decoder instances. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: Optional configuration for the connection. + /// - queue: The dispatch queue used for serializing database operations. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.encoder = .init() + self.decoder = .init() + super.init( + provider: provider, + config: config, + queue: queue + ) + } + + /// Creates a model-aware database service from a connection autoclosure. + /// + /// - Parameters: + /// - provider: A connection autoclosure that returns a database connection. + /// - config: Optional configuration for the connection. + /// - queue: The dispatch queue used for serializing database operations. + public required convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.init( + provider: provider, + config: config, + queue: queue + ) + } +} diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift new file mode 100644 index 0000000..58eb7c8 --- /dev/null +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -0,0 +1,72 @@ +import Foundation +import DataLiteCore + +/// A version storage that persists schema versions in SQLite’s `user_version` field. +/// +/// ## Overview +/// +/// `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 stored version. + public enum Error: Swift.Error { + /// 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 instance of user version storage. + public init() {} + + // MARK: - Version Management + + /// Returns the current schema version stored in the `user_version` field. + /// + /// 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 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) + } + return version + } + + /// Stores the specified schema version in the `user_version` field. + /// + /// Updates the SQLite `PRAGMA user_version` value with the raw `UInt32` representation of the + /// provided `Version`. + /// + /// - Parameters: + /// - connection: The active database connection. + /// - version: The version to store. + 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 new file mode 100644 index 0000000..21f1b09 --- /dev/null +++ b/Sources/DataRaft/Enums/MigrationError.swift @@ -0,0 +1,34 @@ +import Foundation +import DataLiteCore + +/// 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 { + /// Indicates that a migration with the same version or script URL has already been registered. + /// + /// - Parameter migration: The duplicate migration instance. + case duplicateMigration(Migration) + + /// 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/DispatchQueue.swift b/Sources/DataRaft/Extensions/DispatchQueue.swift new file mode 100644 index 0000000..1b80de5 --- /dev/null +++ b/Sources/DataRaft/Extensions/DispatchQueue.swift @@ -0,0 +1,19 @@ +import Foundation + +extension DispatchQueue { + convenience init( + for type: T.Type, + qos: DispatchQoS = .unspecified, + attributes: Attributes = [], + autoreleaseFrequency: AutoreleaseFrequency = .inherit, + target: DispatchQueue? = nil + ) { + self.init( + label: String(describing: type), + qos: qos, + attributes: attributes, + autoreleaseFrequency: autoreleaseFrequency, + target: target + ) + } +} diff --git a/Sources/DataRaft/Extensions/NotificationCenter.swift b/Sources/DataRaft/Extensions/NotificationCenter.swift new file mode 100644 index 0000000..e059186 --- /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 databaseCenter = 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/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift new file mode 100644 index 0000000..21fc8bc --- /dev/null +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -0,0 +1,45 @@ +import Foundation +import DataLiteCore + +/// A type that extends connection management with transactional database operations. +/// +/// ## Overview +/// +/// 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 +/// +/// ### Performing Operations +/// +/// - ``ConnectionServiceProtocol/Perform`` +/// - ``perform(in:closure:)`` +public protocol DatabaseServiceProtocol: ConnectionServiceProtocol { + /// Executes a closure inside a transaction if the connection is in autocommit mode. + /// + /// 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. + /// + /// 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. + /// + /// If a transaction is already active, 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 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 new file mode 100644 index 0000000..93cbfba --- /dev/null +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -0,0 +1,61 @@ +import Foundation + +/// A type that manages and executes database schema migrations. +/// +/// ## Overview +/// +/// 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 { + /// The type representing a schema version used for migrations. + associatedtype Version: VersionRepresentable + + /// 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. + 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 migration step fails to execute + /// or update the stored version. + func migrate() throws(MigrationError) +} + +@available(iOS 13.0, macOS 10.15, *) +public extension MigrationServiceProtocol { + /// 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. + /// + /// - 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() + }.value + } +} diff --git a/Sources/DataRaft/Protocols/VersionStorage.swift b/Sources/DataRaft/Protocols/VersionStorage.swift new file mode 100644 index 0000000..8761e62 --- /dev/null +++ b/Sources/DataRaft/Protocols/VersionStorage.swift @@ -0,0 +1,122 @@ +import Foundation +import DataLiteCore + +/// A type that defines how a database schema version is stored and retrieved. +/// +/// ## Overview +/// +/// This protocol separates the concept of version representation from its persistence mechanism, +/// allowing flexible implementations that store version values in different formats or locations. +/// +/// 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. +/// +/// 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: ConnectionProtocol) throws { +/// let script = """ +/// CREATE TABLE IF NOT EXISTS schema_version ( +/// version TEXT NOT NULL +/// ); +/// +/// INSERT INTO schema_version (version) +/// SELECT '0.0.0' +/// WHERE NOT EXISTS (SELECT 1 FROM schema_version); +/// """ +/// try connection.execute(sql: script) +/// } +/// +/// 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 { +/// throw DatabaseError.message("Missing version in schema_version table.") +/// } +/// return value +/// } +/// +/// 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) +/// try stmt.step() +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Associated Types +/// +/// - ``Version`` +/// +/// ### Instance Methods +/// +/// - ``prepare(_:)`` +/// - ``getVersion(_:)`` +/// - ``setVersion(_:_:)`` +public protocol VersionStorage { + /// 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. + /// + /// Called before any version operations. Use this method to create required tables or metadata + /// structures for version management. + /// + /// - 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: ConnectionProtocol) throws + + /// Returns the current schema version stored in the database. + /// + /// Must return a valid version previously stored by the migration system. + /// + /// - 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: ConnectionProtocol) throws -> Version + + /// Stores the given version as the current schema version. + /// + /// Called at the end of the migration process to persist the final schema version after all + /// migration steps complete successfully. + /// + /// - 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: ConnectionProtocol, _ version: Version) throws +} + +public extension VersionStorage { + func prepare(_ connection: ConnectionProtocol) throws {} +} diff --git a/Sources/DataRaft/Structures/BitPackVersion.swift b/Sources/DataRaft/Structures/BitPackVersion.swift new file mode 100644 index 0000000..5b06d9a --- /dev/null +++ b/Sources/DataRaft/Structures/BitPackVersion.swift @@ -0,0 +1,157 @@ +import Foundation + +/// A semantic version packed into a 32-bit unsigned integer. +/// +/// This type stores a `major.minor.patch` version using bit fields inside a single `UInt32`: +/// +/// - 12 bits for `major` in the 0...4095 range +/// - 12 bits for `minor`in the 0...4095 range +/// - 8 bits for `patch` in the 0...255 range +/// +/// ## Topics +/// +/// ### Errors +/// +/// - ``Error`` +/// - ``ParseError`` +/// +/// ### Creating a Version +/// +/// - ``init(rawValue:)`` +/// - ``init(major:minor:patch:)`` +/// - ``init(version:)`` +/// - ``init(stringLiteral:)`` +/// +/// ### Instance Properties +/// +/// - ``rawValue`` +/// - ``major`` +/// - ``minor`` +/// - ``patch`` +/// - ``description`` +public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStringConvertible { + /// An error related to invalid version components. + public enum Error: Swift.Error { + /// An error for a major component that exceeds the allowed range. + case majorOverflow(UInt32) + + /// An error for a minor component that exceeds the allowed range. + case minorOverflow(UInt32) + + /// An error for a patch component that exceeds the allowed range. + case patchOverflow(UInt32) + + /// A message describing the reason for the error. + public var localizedDescription: String { + switch self { + case .majorOverflow(let value): + "Major version overflow: \(value). Allowed range: 0...4095." + case .minorOverflow(let value): + "Minor version overflow: \(value). Allowed range: 0...4095." + case .patchOverflow(let value): + "Patch version overflow: \(value). Allowed range: 0...255." + } + } + } + + // MARK: - Properties + + /// The packed 32-bit value that encodes the version. + public let rawValue: UInt32 + + /// The major component of the version. + public var major: UInt32 { (rawValue >> 20) & 0xFFF } + + /// The minor component of the version. + public var minor: UInt32 { (rawValue >> 8) & 0xFFF } + + /// The patch component of the version. + public var patch: UInt32 { rawValue & 0xFF } + + /// A string representation in the form `"major.minor.patch"`. + public var description: String { + "\(major).\(minor).\(patch)" + } + + // MARK: - Inits + + /// Creates a version from a packed 32-bit unsigned integer. + /// + /// - Parameter rawValue: A bit-packed version value. + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// Creates a version from individual components. + /// + /// - Parameters: + /// - major: The major component in the 0...4095 range. + /// - minor: The minor component in the 0...4095 range. + /// - patch: The patch component in the 0...255 range. Defaults to `0`. + /// + /// - Throws: ``Error/majorOverflow(_:)`` if `major` is out of range. + /// - Throws: ``Error/minorOverflow(_:)`` if `minor` is out of range. + /// - Throws: ``Error/patchOverflow(_:)`` if `patch` is out of range. + public init(major: UInt32, minor: UInt32, patch: UInt32 = 0) throws { + guard major < (1 << 12) else { throw Error.majorOverflow(major) } + guard minor < (1 << 12) else { throw Error.minorOverflow(minor) } + guard patch < (1 << 8) else { throw Error.patchOverflow(patch) } + self.init(rawValue: (major << 20) | (minor << 8) | patch) + } + + // MARK: - Comparable + + /// Compares two versions by their packed 32-bit values. + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +// MARK: - ExpressibleByStringLiteral + +@available(iOS 16.0, macOS 13.0, *) +extension BitPackVersion: ExpressibleByStringLiteral { + /// An error related to parsing a version string. + public enum ParseError: Swift.Error { + /// A string that doesn't match the expected version format. + case invalidFormat(String) + + /// A message describing the format issue. + public var localizedDescription: String { + switch self { + case .invalidFormat(let str): + "Invalid version format: \(str). Expected something like '1.2' or '1.2.3'." + } + } + } + + /// Creates a version by parsing a string like `"1.2"` or `"1.2.3"`. + /// + /// - Parameter version: A version string in the form `x.y` or `x.y.z`. + /// + /// - Throws: ``ParseError/invalidFormat(_:)`` if the string format is invalid. + /// - Throws: `Error` if any component is out of range. + public init(version: String) throws { + let regex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/ + guard version.wholeMatch(of: regex) != nil else { + throw ParseError.invalidFormat(version) + } + + let parts = version.split(separator: ".") + .compactMap { UInt32($0) } + + try self.init( + major: parts[0], + minor: parts[1], + patch: parts.count == 3 ? parts[2] : 0 + ) + } + + /// Creates a version from a string literal like `"1.2"` or `"1.2.3"`. + /// + /// - Warning: Crashes if the string format is invalid. + /// Use ``init(version:)`` for safe parsing. + public init(stringLiteral value: String) { + try! self.init(version: value) + } +} diff --git a/Sources/DataRaft/Structures/Migration.swift b/Sources/DataRaft/Structures/Migration.swift new file mode 100644 index 0000000..ef5b73a --- /dev/null +++ b/Sources/DataRaft/Structures/Migration.swift @@ -0,0 +1,78 @@ +import Foundation + +/// A database migration step for a specific schema version. +/// +/// ## Overview +/// +/// 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 file URL of the migration script (for example, an SQL file). + public let scriptURL: URL + + /// The migration script as a string. + /// + /// Reads the contents of the file at ``scriptURL`` and trims surrounding whitespace and + /// newlines. + /// + /// - Throws: An error if the script file cannot be read. + public var script: String { + get throws { + try String(contentsOf: scriptURL) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + // MARK: - Inits + + /// Creates a migration with the specified version and script URL. + /// + /// - Parameters: + /// - version: The version this migration corresponds to. + /// - scriptURL: The URL of the script file to execute. + public init(version: Version, scriptURL: URL) { + self.version = version + self.scriptURL = scriptURL + } + + /// Creates a migration by locating a script resource in the specified bundle. + /// + /// 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. 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` 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 + } + self.init(version: version, scriptURL: url) + } +} diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift new file mode 100644 index 0000000..6b605e2 --- /dev/null +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing +import DataLiteC +import DataLiteCore +import DataRaft + +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, + 0xa2, 0xd7, 0x78, 0xe9, 0xe5, 0x87, 0x05, 0xb4, + 0xe2, 0x1a, 0x42, 0x74, 0xee, 0xbc, 0x4c, 0x06 + ])) + + private let keyTwo = Connection.Key.rawKey(Data([ + 0x9f, 0x45, 0x23, 0xbf, 0xfe, 0x11, 0x3e, 0x79, + 0x42, 0x21, 0x48, 0x7c, 0xb6, 0xb1, 0xd5, 0x09, + 0x34, 0x5f, 0xcb, 0x53, 0xa3, 0xdd, 0x8e, 0x41, + 0x95, 0x27, 0xbb, 0x4e, 0x6e, 0xd8, 0xa7, 0x05 + ])) + + private let fileURL: URL + private let service: DatabaseService + + private lazy var currentKey = keyOne + + init() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("sqlite") + + let service = DatabaseService(provider: { + try Connection( + path: fileURL.path, + options: [.create, .readwrite] + ) + }) + + self.fileURL = fileURL + self.service = service + self.service.keyProvider = self + + try self.service.perform { connection in + try connection.execute(sql: """ + CREATE TABLE IF NOT EXISTS Item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + """) + } + } + + deinit { + try? FileManager.default.removeItem(at: fileURL) + } + + func connectionService(keyFor service: any ConnectionServiceProtocol) throws -> Connection.Key { + currentKey + } + + func connectionService(shouldReconnect service: any ConnectionServiceProtocol) -> Bool { + true + } + + func connectionService(_ service: any ConnectionServiceProtocol, didReceive error: any Error) { + } +} + +extension DatabaseServiceTests { + @Test func testSuccessPerformTransaction() throws { + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testNestedPerformTransaction() throws { + try service.perform(in: .deferred) { _ in + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testRollbackPerformTransaction() throws { + struct DummyError: Error, Equatable {} + #expect(throws: DummyError(), performing: { + try self.service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + throw DummyError() + } + }) + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 0) + } + } + + @Test func testSuccessReconnectPerformTransaction() throws { + let connection = try Connection( + path: fileURL.path, + options: [.readwrite] + ) + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) + currentKey = keyTwo + + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testFailReconnectPerformTransaction() throws { + let connection = try Connection( + path: fileURL.path, + options: [.readwrite] + ) + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) + let error = SQLiteError( + code: SQLITE_NOTADB, + message: "file is not a database" + ) + #expect(throws: error, performing: { + try self.service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + }) + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 0) + } +} diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift new file mode 100644 index 0000000..84e7849 --- /dev/null +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -0,0 +1,102 @@ +import Testing +import DataLiteCore +@testable import DataRaft + +@Suite struct MigrationServiceTests { + private typealias MigrationService = DataRaft.MigrationService + private typealias MigrationError = DataRaft.MigrationError + + private var connection: Connection! + private var migrationService: MigrationService! + + init() throws { + let connection = try Connection(location: .inMemory, options: .readwrite) + self.connection = connection + self.migrationService = .init(connection: connection) + } + + @Test func addMigration() 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 migration3 = Migration(version: 3, byResource: "migration_2", extension: "sql", in: .module)! + + #expect(try migrationService.add(migration1) == ()) + #expect(try migrationService.add(migration2) == ()) + + do { + try migrationService.add(migration3) + Issue.record("Expected duplicateMigration error for version \(migration3.version)") + } catch MigrationError.duplicateMigration(let migration) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func migrate() 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)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try await migrationService.migrate() + + #expect(connection.userVersion == 2) + } + + @Test func migrateError() 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 migration3 = Migration(version: 3, byResource: "migration_3", extension: "sql", in: .module)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try migrationService.add(migration3) + + do { + try await migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration3.version)") + } catch MigrationError.migrationFailed(let migration, _) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(connection.userVersion == 0) + } + + @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: "empty", extension: "sql", in: .module)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try migrationService.add(migration4) + + do { + try await migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration4.version)") + } catch MigrationError.emptyMigrationScript(let migration) { + #expect(migration == migration4) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(connection.userVersion == 0) + } +} + +private extension MigrationServiceTests { + struct VersionStorage: DataRaft.VersionStorage { + typealias Version = Int32 + + func getVersion(_ connection: ConnectionProtocol) throws -> Version { + connection.userVersion + } + + func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { + connection.userVersion = version + } + } +} diff --git a/Tests/DataRaftTests/Classes/UserVersionStorage.swift b/Tests/DataRaftTests/Classes/UserVersionStorage.swift new file mode 100644 index 0000000..f595dd2 --- /dev/null +++ b/Tests/DataRaftTests/Classes/UserVersionStorage.swift @@ -0,0 +1,64 @@ +import Testing +import DataLiteCore +@testable import DataRaft + +@Suite struct UserVersionStorageTests { + private var connection: Connection! + + init() throws { + connection = try .init(location: .inMemory, options: .readwrite) + } + + @Test func getVersion() throws { + connection.userVersion = 123 + let storage = UserVersionStorage() + let version = try storage.getVersion(connection) + #expect(version == Version(rawValue: 123)) + } + + @Test func getVersionWithError() { + connection.userVersion = 123 + let storage = UserVersionStorage() + do { + _ = try storage.getVersion(connection) + Issue.record("Expected failure for invalid stored version") + } catch UserVersionStorage.Error.invalidStoredVersion(let version) { + #expect(version == UInt32(bitPattern: connection.userVersion)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func setVersion() throws { + let storage = UserVersionStorage() + let version = Version(rawValue: 456) + try storage.setVersion(connection, version) + #expect(connection.userVersion == 456) + } +} + +private extension UserVersionStorageTests { + struct Version: RawRepresentable, VersionRepresentable, Equatable { + let rawValue: UInt32 + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + struct NilVersion: RawRepresentable, VersionRepresentable { + let rawValue: UInt32 + + init?(rawValue: UInt32) { + return nil + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } +} 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_1.sql b/Tests/DataRaftTests/Resources/migration_1.sql new file mode 100644 index 0000000..8053cb0 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_1.sql @@ -0,0 +1,12 @@ +-- Create table User +CREATE TABLE User ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +-- Insert values into User +INSERT INTO User (id, name, email) +VALUES + (1, 'john_doe', 'john@example.com'), -- Inserting John Doe + (2, 'jane_doe', 'jane@example.com'); -- Inserting Jane Doe diff --git a/Tests/DataRaftTests/Resources/migration_2.sql b/Tests/DataRaftTests/Resources/migration_2.sql new file mode 100644 index 0000000..acab84b --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_2.sql @@ -0,0 +1,11 @@ +-- Create table Device +CREATE TABLE Device ( + id INTEGER PRIMARY KEY, + model TEXT NOT NULL +); + +-- Insert values into Device +INSERT INTO Device (id, model) +VALUES + (1, 'iPhone 14'), -- Inserting iPhone 14 + (2, 'iPhone 15'); -- Inserting iPhone 15 diff --git a/Tests/DataRaftTests/Resources/migration_3.sql b/Tests/DataRaftTests/Resources/migration_3.sql new file mode 100644 index 0000000..48d1604 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_3.sql @@ -0,0 +1,2 @@ +-- Wrong sql statement +WRONG SQL STATEMENT; diff --git a/Tests/DataRaftTests/Structures/BitPackVersionTests.swift b/Tests/DataRaftTests/Structures/BitPackVersionTests.swift new file mode 100644 index 0000000..6f75237 --- /dev/null +++ b/Tests/DataRaftTests/Structures/BitPackVersionTests.swift @@ -0,0 +1,182 @@ +import Testing +import DataRaft + +@Suite struct BitPackVersionTests { + @Test(arguments: [ + (0, 0, 0, 0, "0.0.0"), + (0, 0, 1, 1, "0.0.1"), + (0, 1, 0, 256, "0.1.0"), + (1, 0, 0, 1048576, "1.0.0"), + (15, 15, 15, 15732495, "15.15.15"), + (123, 456, 78, 129091662, "123.456.78"), + (255, 255, 255, 267452415, "255.255.255"), + (4095, 4095, 255, 4294967295, "4095.4095.255") + ]) + func versionComponents( + _ major: UInt32, + _ minor: UInt32, + _ patch: UInt32, + _ rawValue: UInt32, + _ description: String + ) throws { + let version = try BitPackVersion( + major: major, + minor: minor, + patch: patch + ) + + #expect(version.major == major) + #expect(version.minor == minor) + #expect(version.patch == patch) + #expect(version.rawValue == rawValue) + #expect(version.description == description) + } + + @Test func majorOverflow() { + do { + _ = try BitPackVersion(major: 4096, minor: 0) + Issue.record("Expected BitPackVersion.Error.majorOverflow, but succeeded") + } catch BitPackVersion.Error.majorOverflow(let value) { + let error = BitPackVersion.Error.majorOverflow(value) + let description = "Major version overflow: \(value). Allowed range: 0...4095." + #expect(value == 4096) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func minorOverflow() { + do { + _ = try BitPackVersion(major: 0, minor: 4096) + Issue.record("Expected BitPackVersion.Error.minorOverflow, but succeeded") + } catch BitPackVersion.Error.minorOverflow(let value) { + let error = BitPackVersion.Error.minorOverflow(value) + let description = "Minor version overflow: \(value). Allowed range: 0...4095." + #expect(value == 4096) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func patchOverflow() { + do { + _ = try BitPackVersion(major: 0, minor: 0, patch: 256) + Issue.record("Expected BitPackVersion.Error.patchOverflow, but succeeded") + } catch BitPackVersion.Error.patchOverflow(let value) { + let error = BitPackVersion.Error.patchOverflow(value) + let description = "Patch version overflow: \(value). Allowed range: 0...255." + #expect(value == 256) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test(arguments: try [ + ( + BitPackVersion(major: 0, minor: 0, patch: 0), + BitPackVersion(major: 0, minor: 0, patch: 1) + ), + ( + BitPackVersion(major: 0, minor: 1, patch: 0), + BitPackVersion(major: 1, minor: 0, patch: 0) + ), + ( + BitPackVersion(major: 1, minor: 1, patch: 1), + BitPackVersion(major: 1, minor: 1, patch: 2) + ), + ( + BitPackVersion(major: 5, minor: 0, patch: 255), + BitPackVersion(major: 5, minor: 1, patch: 0) + ), + ( + BitPackVersion(major: 10, minor: 100, patch: 100), + BitPackVersion(major: 11, minor: 0, patch: 0) + ), + ( + BitPackVersion(major: 4094, minor: 4095, patch: 255), + BitPackVersion(major: 4095, minor: 0, patch: 0) + ) + ]) + func compare( + _ versionOne: BitPackVersion, + _ versionTwo: BitPackVersion + ) throws { + #expect(versionOne < versionTwo) + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test(arguments: [ + ("0.0.0", 0, 0, 0, 0, "0.0.0"), + ("0.0.1", 0, 0, 1, 1, "0.0.1"), + ("0.1.0", 0, 1, 0, 256, "0.1.0"), + ("1.0.0", 1, 0, 0, 1048576, "1.0.0"), + ("1.2.3", 1, 2, 3, 1049091, "1.2.3"), + ("123.456.78", 123, 456, 78, 129091662, "123.456.78"), + ("4095.4095.255", 4095, 4095, 255, 4294967295, "4095.4095.255"), + ("10.20", 10, 20, 0, 10490880, "10.20.0"), + ("42.0.13", 42, 0, 13, 44040205, "42.0.13") + ]) + func fromString( + _ string: String, + _ major: UInt32, + _ minor: UInt32, + _ patch: UInt32, + _ rawValue: UInt32, + _ description: String + ) throws { + let version = try BitPackVersion(version: string) + + #expect(version.major == major) + #expect(version.minor == minor) + #expect(version.patch == patch) + #expect(version.rawValue == rawValue) + #expect(version.description == description) + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test(arguments: [ + "", + "1", + "1.", + ".1", + "1.2.3.4", + "1.2.", + "1..2", + "a.b.c", + "1.2.c", + "01.2.3", + "1.02.3", + "1.2.03", + " 1.2.3", + "1.2.3 ", + " 1.2 ", + "1,2,3", + ]) + func fromInvalidStrings(_ input: String) { + do { + _ = try BitPackVersion(version: input) + Issue.record("Expected failure for: \(input)") + } catch BitPackVersion.ParseError.invalidFormat(let str) { + let error = BitPackVersion.ParseError.invalidFormat(str) + let description = "Invalid version format: \(str). Expected something like '1.2' or '1.2.3'." + #expect(str == input) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error for: \(input) — \(error)") + } + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test func stringLiteralInit() { + let version: BitPackVersion = "1.2.3" + #expect(version.major == 1) + #expect(version.minor == 2) + #expect(version.patch == 3) + } +} diff --git a/Tests/DataRaftTests/Structures/MigrationTests.swift b/Tests/DataRaftTests/Structures/MigrationTests.swift new file mode 100644 index 0000000..b4541ae --- /dev/null +++ b/Tests/DataRaftTests/Structures/MigrationTests.swift @@ -0,0 +1,68 @@ +import Testing +import Foundation + +@testable import DataRaft + +@Suite struct MigrationTests { + @Test func initWithURL() { + let version = DummyVersion(rawValue: 1) + let url = URL(fileURLWithPath: "/tmp/migration.sql") + let migration = Migration(version: version, scriptURL: url) + + #expect(migration.version == version) + #expect(migration.scriptURL == url) + } + + @Test func initFromBundle_success() throws { + let version = DummyVersion(rawValue: 2) + + let migration = Migration( + version: version, + byResource: "migration_1", + extension: "sql", + in: .module + ) + + #expect(migration != nil) + #expect(migration?.version == version) + #expect(migration?.scriptURL.lastPathComponent == "migration_1.sql") + } + + @Test func initFromBundle_failure() { + let version = DummyVersion(rawValue: 3) + + let migration = Migration( + version: version, + byResource: "NonexistentFile", + extension: "sql", + in: .main + ) + + #expect(migration == nil) + } + + @Test func hashableEquatable() { + let version = DummyVersion(rawValue: 5) + let url = URL(fileURLWithPath: "/tmp/migration.sql") + + let migration1 = Migration(version: version, scriptURL: url) + let migration2 = Migration(version: version, scriptURL: url) + + #expect(migration1 == migration2) + #expect(migration1.hashValue == migration2.hashValue) + } +} + +private extension MigrationTests { + struct DummyVersion: VersionRepresentable { + let rawValue: UInt32 + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } +}