diff --git a/Package.swift b/Package.swift deleted file mode 100644 index b9ebf06..0000000 --- a/Package.swift +++ /dev/null @@ -1,42 +0,0 @@ -// 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 11ddbce..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index 071cd4e..0000000 --- a/Sources/DataRaft/Aliases/VersionRepresentable.swift +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index fb8708d..0000000 --- a/Sources/DataRaft/Classes/ConnectionService.swift +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 79686fa..0000000 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 840b06a..0000000 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index 7f0599e..0000000 --- a/Sources/DataRaft/Classes/ModelDatabaseService.swift +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index 58eb7c8..0000000 --- a/Sources/DataRaft/Classes/UserVersionStorage.swift +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 21f1b09..0000000 --- a/Sources/DataRaft/Enums/MigrationError.swift +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 1b80de5..0000000 --- a/Sources/DataRaft/Extensions/DispatchQueue.swift +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index e059186..0000000 --- a/Sources/DataRaft/Extensions/NotificationCenter.swift +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 2688b9c..0000000 --- a/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index f69774b..0000000 --- a/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 21fc8bc..0000000 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 93cbfba..0000000 --- a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 8761e62..0000000 --- a/Sources/DataRaft/Protocols/VersionStorage.swift +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 5b06d9a..0000000 --- a/Sources/DataRaft/Structures/BitPackVersion.swift +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index ef5b73a..0000000 --- a/Sources/DataRaft/Structures/Migration.swift +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 6b605e2..0000000 --- a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift +++ /dev/null @@ -1,197 +0,0 @@ -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 deleted file mode 100644 index 84e7849..0000000 --- a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index f595dd2..0000000 --- a/Tests/DataRaftTests/Classes/UserVersionStorage.swift +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 8b13789..0000000 --- a/Tests/DataRaftTests/Resources/empty.sql +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Tests/DataRaftTests/Resources/migration_1.sql b/Tests/DataRaftTests/Resources/migration_1.sql deleted file mode 100644 index 8053cb0..0000000 --- a/Tests/DataRaftTests/Resources/migration_1.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 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 deleted file mode 100644 index acab84b..0000000 --- a/Tests/DataRaftTests/Resources/migration_2.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 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 deleted file mode 100644 index 48d1604..0000000 --- a/Tests/DataRaftTests/Resources/migration_3.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Wrong sql statement -WRONG SQL STATEMENT; diff --git a/Tests/DataRaftTests/Structures/BitPackVersionTests.swift b/Tests/DataRaftTests/Structures/BitPackVersionTests.swift deleted file mode 100644 index 6f75237..0000000 --- a/Tests/DataRaftTests/Structures/BitPackVersionTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -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 deleted file mode 100644 index b4541ae..0000000 --- a/Tests/DataRaftTests/Structures/MigrationTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -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 - } - } -}