From 1b2cdaf23edd4970f89dc1f14eca33049c03f822 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Sun, 18 May 2025 17:47:22 +0300 Subject: [PATCH] DataRaft swift package --- Package.swift | 48 ++++ README.md | 62 +++++ .../DataRaft/Classes/DatabaseService.swift | 251 ++++++++++++++++++ .../DataRaft/Classes/MigrationService.swift | 138 ++++++++++ .../DataRaft/Classes/RowDatabaseService.swift | 111 ++++++++ .../DataRaft/Classes/UserVersionStorage.swift | 66 +++++ .../DataRaft/Extensions/DispatchQueue.swift | 19 ++ .../DatabaseServiceKeyProvider.swift | 53 ++++ .../Protocols/DatabaseServiceProtocol.swift | 55 ++++ .../RowDatabaseServiceProtocol.swift | 17 ++ .../Protocols/VersionRepresentable.swift | 33 +++ .../DataRaft/Protocols/VersionStorage.swift | 140 ++++++++++ .../DataRaft/Structures/BitPackVersion.swift | 158 +++++++++++ Sources/DataRaft/Structures/Migration.swift | 75 ++++++ .../Classes/DatabaseServiceTests.swift | 194 ++++++++++++++ .../Classes/MigrationServiceTests.swift | 101 +++++++ .../Classes/UserVersionStorage.swift | 64 +++++ Tests/DataRaftTests/Resources/migration_1.sql | 12 + Tests/DataRaftTests/Resources/migration_2.sql | 11 + Tests/DataRaftTests/Resources/migration_3.sql | 2 + Tests/DataRaftTests/Resources/migration_4.sql | 1 + .../Structures/BitPackVersionTests.swift | 182 +++++++++++++ .../Structures/MigrationTests.swift | 69 +++++ 23 files changed, 1862 insertions(+) create mode 100644 Package.swift create mode 100644 Sources/DataRaft/Classes/DatabaseService.swift create mode 100644 Sources/DataRaft/Classes/MigrationService.swift create mode 100644 Sources/DataRaft/Classes/RowDatabaseService.swift create mode 100644 Sources/DataRaft/Classes/UserVersionStorage.swift create mode 100644 Sources/DataRaft/Extensions/DispatchQueue.swift create mode 100644 Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift create mode 100644 Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift create mode 100644 Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift create mode 100644 Sources/DataRaft/Protocols/VersionRepresentable.swift create mode 100644 Sources/DataRaft/Protocols/VersionStorage.swift create mode 100644 Sources/DataRaft/Structures/BitPackVersion.swift create mode 100644 Sources/DataRaft/Structures/Migration.swift create mode 100644 Tests/DataRaftTests/Classes/DatabaseServiceTests.swift create mode 100644 Tests/DataRaftTests/Classes/MigrationServiceTests.swift create mode 100644 Tests/DataRaftTests/Classes/UserVersionStorage.swift create mode 100644 Tests/DataRaftTests/Resources/migration_1.sql create mode 100644 Tests/DataRaftTests/Resources/migration_2.sql create mode 100644 Tests/DataRaftTests/Resources/migration_3.sql create mode 100644 Tests/DataRaftTests/Resources/migration_4.sql create mode 100644 Tests/DataRaftTests/Structures/BitPackVersionTests.swift create mode 100644 Tests/DataRaftTests/Structures/MigrationTests.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8ee30ed --- /dev/null +++ b/Package.swift @@ -0,0 +1,48 @@ +// 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", + revision: "5c6942bd0b9636b5ac3e550453c07aac843e8416" + ), + .package( + url: "https://github.com/angd-dev/data-lite-coder.git", + revision: "5aec6ea5784dd5bd098bfa98036fbdc362a8931c" + ), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ], + 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/migration_1.sql"), + .copy("Resources/migration_2.sql"), + .copy("Resources/migration_3.sql"), + .copy("Resources/migration_4.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/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift new file mode 100644 index 0000000..5c80e2d --- /dev/null +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -0,0 +1,251 @@ +import Foundation +import DataLiteCore +import DataLiteC + +/// A base class for services that operate on a database connection. +/// +/// `DatabaseService` provides a shared interface for executing operations on a `Connection`, +/// with support for transaction handling and optional request serialization. +/// +/// Subclasses can use this base to coordinate safe, synchronous access to the database +/// without duplicating concurrency or transaction logic. +/// +/// For example, you can define a custom service for managing notes: +/// +/// ```swift +/// final class NoteService: DatabaseService { +/// func insertNote(_ text: String) throws { +/// try perform { connection in +/// let stmt = try connection.prepare( +/// sql: "INSERT INTO notes (text) VALUES (?)" +/// ) +/// try stmt.bind(text, at: 0) +/// try stmt.step() +/// } +/// } +/// +/// func fetchNotes() throws -> [String] { +/// try perform { connection in +/// let stmt = try connection.prepare(sql: "SELECT text FROM notes") +/// var result: [String] = [] +/// while try stmt.step() { +/// if let text: String = stmt.columnValue(at: 0) { +/// result.append(text) +/// } +/// } +/// return result +/// } +/// } +/// } +/// +/// let connection = try Connection(location: .inMemory, options: .readwrite) +/// let service = NoteService(connection: connection) +/// +/// try service.insertNote("Hello, world!") +/// let notes = try service.fetchNotes() +/// print(notes) // ["Hello, world!"] +/// ``` +/// +/// This approach allows you to build reusable service layers on top of a safe, transactional, +/// and serialized foundation. +open class DatabaseService: DatabaseServiceProtocol { + /// A closure that provides a new database connection when invoked. + /// + /// `ConnectionProvider` is used to defer the creation of a `Connection` instance + /// until it is actually needed. It can throw errors if the connection cannot be + /// established or configured correctly. + /// + /// - Returns: A valid `Connection` instance. + /// - Throws: Any error encountered while opening or configuring the connection. + public typealias ConnectionProvider = () throws -> Connection + + // MARK: - Properties + + private let provider: ConnectionProvider + private var connection: Connection + private let queue: DispatchQueue + private let queueKey = DispatchSpecificKey() + + /// The object that provides the encryption key for the database connection. + /// + /// When this property is set, the service attempts to retrieve an encryption key from the + /// provider and apply it to the current database connection. This operation is performed + /// synchronously on the service’s internal queue to ensure thread safety. + /// + /// If an error occurs during key retrieval or application, the service notifies the provider + /// by calling `databaseService(_:didReceive:)`. + /// + /// This enables external management of encryption keys, including features such as key rotation, + /// user-scoped encryption, or error handling delegation. + /// + /// - Important: The service does not retry failed key applications. Ensure the provider is + /// correctly configured and able to supply a valid key when needed. + public weak var keyProvider: DatabaseServiceKeyProvider? { + didSet { + perform { connection in + do { + if let key = try keyProvider?.databaseServiceKey(self) { + try connection.apply(key) + } + } catch { + keyProvider?.databaseService(self, didReceive: error) + } + } + } + } + + // MARK: - Inits + + /// Creates a new `DatabaseService` using the given connection provider and optional queue. + /// + /// This convenience initializer wraps the provided autoclosure in a `ConnectionProvider` + /// and delegates to the designated initializer. It is useful when passing a simple + /// connection expression. + /// + /// - Parameters: + /// - provider: A closure that returns a `Connection` instance and may throw. + /// - queue: An optional dispatch queue used as a target for internal serialization. If `nil`, + /// a default serial queue with `.utility` QoS is created internally. + /// - Throws: Rethrows any error thrown by the connection provider. + public convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + queue: DispatchQueue? = nil + ) rethrows { + try self.init(provider: provider, queue: queue) + } + + /// Creates a new `DatabaseService` with the specified connection provider and dispatch queue. + /// + /// This initializer immediately invokes the `provider` closure to establish the initial database + /// connection. An internal serial queue is created for synchronizing database access. If a + /// `queue` is provided, it is set as the target of the internal queue, allowing you to control + /// scheduling and quality of service. + /// + /// - Parameters: + /// - provider: A closure that returns a new `Connection` instance. May throw on failure. + /// - queue: An optional dispatch queue to target for internal serialization. If `nil`, + /// a dedicated serial queue with `.utility` QoS is created. + /// - Throws: Any error thrown by the `provider` during initial connection setup. + public init( + provider: @escaping ConnectionProvider, + queue: DispatchQueue? = nil + ) rethrows { + self.provider = provider + self.connection = try provider() + self.queue = .init(for: Self.self, qos: .utility) + self.queue.setSpecific(key: queueKey, value: ()) + if let queue = queue { + self.queue.setTarget(queue: queue) + } + } + + // MARK: - Methods + + /// Re-establishes the database connection using the stored connection provider. + /// + /// This method creates a new `Connection` instance by invoking the original provider. If a + /// `keyProvider` is set, the method attempts to retrieve and apply an encryption key to the new + /// connection. The new connection replaces the existing one. + /// + /// The operation is executed synchronously on the internal dispatch queue via `perform(_:)` + /// to ensure thread safety. + /// + /// - Throws: Any error thrown during connection creation or while retrieving or applying the + /// encryption key. + public func reconnect() throws { + try perform { _ in + let connection = try provider() + if let key = try keyProvider?.databaseServiceKey(self) { + try connection.apply(key) + } + self.connection = connection + } + } + + /// Executes the given closure using the active database connection. + /// + /// This method ensures thread-safe access to the underlying `Connection` by synchronizing + /// execution on an internal serial dispatch queue. If the call is already on that queue, the + /// closure is executed directly to avoid unnecessary dispatching. + /// + /// If the closure throws a `SQLiteError` with code `SQLITE_NOTADB` (e.g., when the database file + /// is corrupted or invalid), the service attempts to re-establish the connection by calling + /// ``reconnect()``. The error is still rethrown after reconnection. + /// + /// - Parameter closure: A closure that takes the active connection and returns a result. + /// - Returns: The value returned by the closure. + /// - Throws: Any error thrown by the closure or during reconnection logic. + public func perform(_ closure: Perform) rethrows -> T { + do { + switch DispatchQueue.getSpecific(key: queueKey) { + case .none: return try queue.asyncAndWait { try closure(connection) } + case .some: return try closure(connection) + } + } catch { + switch error { + case let error as Connection.Error: + if error.code == SQLITE_NOTADB { + try reconnect() + } + fallthrough + default: + throw error + } + } + } + + /// Executes a closure inside a transaction if the connection is in autocommit mode. + /// + /// If the current connection is in autocommit mode, a new transaction of the specified type + /// is started, and the closure is executed within it. If the closure completes successfully, + /// the transaction is committed. If an error is thrown, the transaction is rolled back. + /// + /// If the thrown error is a `SQLiteError` with code `SQLITE_NOTADB`, the service attempts to + /// reconnect and retries the entire transaction block exactly once. + /// + /// If the connection is already within a transaction (i.e., not in autocommit mode), + /// the closure is executed directly without starting a new transaction. + /// + /// - Parameters: + /// - transaction: The type of transaction to begin (e.g., `deferred`, `immediate`, `exclusive`). + /// - closure: A closure that takes the active connection and returns a result. + /// - Returns: The value returned by the closure. + /// - Throws: Any error thrown by the closure, transaction control statements, + /// or reconnect logic. + public func perform( + in transaction: TransactionType, + closure: Perform + ) rethrows -> T { + if connection.isAutocommit { + try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + try connection.rollbackTransaction() + guard let error = error as? Connection.Error, + error.code == SQLITE_NOTADB + else { throw error } + + try reconnect() + + return try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + try connection.rollbackTransaction() + throw error + } + } + } + } + } else { + try perform(closure) + } + } +} diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift new file mode 100644 index 0000000..c3324a2 --- /dev/null +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -0,0 +1,138 @@ +import Foundation +import DataLiteCore + +/// A service responsible for managing and applying database migrations in a versioned manner. +/// +/// `MigrationService` manages a collection of migrations identified by versions and script URLs, +/// and applies them sequentially to update the database schema. It ensures that each migration +/// is applied only once, and in the correct version order based on the current database version. +/// +/// This service is generic over a `VersionStorage` implementation that handles storing and +/// retrieving the current database version. Migrations must have unique versions and script URLs +/// to prevent duplication. +/// +/// ```swift +/// let connection = try Connection(location: .inMemory, options: .readwrite) +/// let storage = UserVersionStorage() +/// let service = MigrationService(storage: storage, connection: connection) +/// +/// try service.add(Migration(version: "1.0.0", byResource: "v_1_0_0.sql")!) +/// try service.add(Migration(version: "1.0.1", byResource: "v_1_0_1.sql")!) +/// try service.add(Migration(version: "1.1.0", byResource: "v_1_1_0.sql")!) +/// try service.add(Migration(version: "1.2.0", byResource: "v_1_2_0.sql")!) +/// +/// try service.migrate() +/// ``` +/// +/// ### Custom Versions and Storage +/// +/// You can customize versioning by providing your own `Version` type conforming to +/// ``VersionRepresentable``, which supports comparison, hashing, and identity checks. +/// +/// The storage backend (`VersionStorage`) defines how the version is persisted, such as +/// in a pragma, table, or metadata. +/// +/// This allows using semantic versions, integers, or other schemes, and storing them +/// in custom places. +public final class MigrationService { + /// The version type used by this migration service, derived from the storage type. + public typealias Version = Storage.Version + + /// Errors that may occur during migration registration or execution. + public enum Error: Swift.Error { + /// A migration with the same version or script URL was already registered. + case duplicateMigration(Migration) + + /// Migration execution failed, with optional reference to the failed migration. + case migrationFailed(Migration?, Swift.Error) + + /// The migration script is empty. + case emptyMigrationScript(Migration) + } + + // MARK: - Properties + + private let service: Service + private let storage: Storage + private var migrations = Set>() + + /// The encryption key provider delegated to the underlying database service. + public weak var keyProvider: DatabaseServiceKeyProvider? { + get { service.keyProvider } + set { service.keyProvider = newValue } + } + + // MARK: - Inits + + /// Creates a new migration service with the given database service and version storage. + /// + /// - Parameters: + /// - service: The database service used to perform migrations. + /// - storage: The version storage implementation used to track the current schema version. + public init( + service: Service, + storage: Storage + ) { + self.service = service + self.storage = storage + } + + // MARK: - Migration Management + + /// Registers a new migration. + /// + /// Ensures that no other migration with the same version or script URL has been registered. + /// + /// - Parameter migration: The migration to register. + /// - Throws: ``Error/duplicateMigration(_:)`` if the migration version or script URL duplicates an existing one. + public func add(_ migration: Migration) throws { + guard !migrations.contains(where: { + $0.version == migration.version + || $0.scriptURL == migration.scriptURL + }) else { + throw Error.duplicateMigration(migration) + } + migrations.insert(migration) + } + + /// Executes all pending migrations in ascending version order. + /// + /// This method retrieves the current schema version from the storage, filters and sorts + /// pending migrations, executes each migration script within a single exclusive transaction, + /// and updates the schema version on success. + /// + /// If a migration script is empty or a migration fails, the process aborts and rolls back changes. + /// + /// - Throws: ``Error/migrationFailed(_:_:)`` if a migration script fails or if updating the version fails. + public func migrate() throws { + do { + try service.perform(in: .exclusive) { connection in + try storage.prepare(connection) + let version = try storage.getVersion(connection) + let migrations = migrations + .filter { $0.version > version } + .sorted { $0.version < $1.version } + + for migration in migrations { + let script = try migration.script + guard !script.isEmpty else { + throw Error.emptyMigrationScript(migration) + } + do { + try connection.execute(sql: script) + } catch { + throw Error.migrationFailed(migration, error) + } + } + + if let version = migrations.last?.version { + try storage.setVersion(connection, version) + } + } + } catch let error as Error { + throw error + } catch { + throw Error.migrationFailed(nil, error) + } + } +} diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift new file mode 100644 index 0000000..079d382 --- /dev/null +++ b/Sources/DataRaft/Classes/RowDatabaseService.swift @@ -0,0 +1,111 @@ +import Foundation +import DataLiteCore +import DataLiteCoder + +/// A database service that provides built-in row encoding and decoding. +/// +/// `RowDatabaseService` extends `DatabaseService` by adding support for +/// value serialization using `RowEncoder` and deserialization using `RowDecoder`. +/// +/// This enables subclasses to perform type-safe operations on models +/// encoded from or decoded into SQLite row representations. +/// +/// For example, a concrete service might define model-aware fetch or insert methods: +/// +/// ```swift +/// struct User: Codable { +/// let id: Int +/// let name: String +/// } +/// +/// final class UserService: RowDatabaseService { +/// func fetchUsers() throws -> [User] { +/// try perform(in: .deferred) { connection in +/// let stmt = try connection.prepare(sql: "SELECT * FROM users") +/// let rows = try stmt.execute() +/// return try decoder.decode([User].self, from: rows) +/// } +/// } +/// +/// func insertUser(_ user: User) throws { +/// try perform(in: .deferred) { connection in +/// let row = try encoder.encode(user) +/// let columns = row.columns.joined(separator: ", ") +/// let parameters = row.namedParameters.joined(separator: ", ") +/// let stmt = try connection.prepare( +/// sql: "INSERT INTO users (\(columns)) VALUES (\(parameters))" +/// ) +/// try stmt.execute(rows: [row]) +/// } +/// } +/// } +/// ``` +/// +/// `RowDatabaseService` encourages a reusable, type-safe pattern for +/// model-based interaction with SQLite while preserving thread safety +/// and transactional integrity. +open class RowDatabaseService: DatabaseService, RowDatabaseServiceProtocol { + // MARK: - Properties + + /// The encoder used to serialize values into row representations. + public let encoder: RowEncoder + + /// The decoder used to deserialize row values into strongly typed models. + public let decoder: RowDecoder + + // MARK: - Inits + + /// Creates a new `RowDatabaseService`. + /// + /// This initializer accepts a closure that supplies the database connection. If no encoder + /// or decoder is provided, default instances are used. + /// + /// - Parameters: + /// - provider: A closure that returns a `Connection` instance. May throw an error. + /// - encoder: The encoder used to serialize models into SQLite-compatible rows. + /// Defaults to a new encoder. + /// - decoder: The decoder used to deserialize SQLite rows into typed models. + /// Defaults to a new decoder. + /// - queue: An optional dispatch queue used for serialization. If `nil`, an internal + /// serial queue with `.utility` QoS is created. + /// - Throws: Any error thrown by the connection provider. + public convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + encoder: RowEncoder = RowEncoder(), + decoder: RowDecoder = RowDecoder(), + queue: DispatchQueue? = nil + ) rethrows { + try self.init( + provider: provider, + encoder: encoder, + decoder: decoder, + queue: queue + ) + } + + /// Designated initializer for `RowDatabaseService`. + /// + /// Initializes a new instance with the specified connection provider, encoder, decoder, + /// and an optional dispatch queue for synchronization. + /// + /// - Parameters: + /// - provider: A closure that returns a `Connection` instance. May throw an error. + /// - encoder: A custom `RowEncoder` used for encoding model data. Defaults to a new encoder. + /// - decoder: A custom `RowDecoder` used for decoding database rows. Defaults to a new decoder. + /// - queue: An optional dispatch queue for serializing access to the database connection. + /// If `nil`, a default internal serial queue with `.utility` QoS is used. + /// - Throws: Any error thrown by the connection provider. + public init( + provider: @escaping ConnectionProvider, + encoder: RowEncoder = RowEncoder(), + decoder: RowDecoder = RowDecoder(), + queue: DispatchQueue? = nil + ) rethrows { + self.encoder = encoder + self.decoder = decoder + try super.init( + provider: provider, + queue: queue + ) + } +} diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift new file mode 100644 index 0000000..1da6303 --- /dev/null +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -0,0 +1,66 @@ +import Foundation +import DataLiteCore + +/// A database version storage that uses the `user_version` field. +/// +/// This class implements ``VersionStorage`` by storing version information +/// in the SQLite `PRAGMA user_version` field. It provides a lightweight, +/// type-safe way to persist versioning data in a database. +/// +/// The generic `Version` type must conform to both ``VersionRepresentable`` +/// and `RawRepresentable`, where `RawValue == UInt32`. This allows +/// converting between stored integer values and semantic version types +/// defined by the application. +public final class UserVersionStorage< + Version: VersionRepresentable & RawRepresentable +>: VersionStorage where Version.RawValue == UInt32 { + /// Errors related to reading or decoding the version. + public enum Error: Swift.Error { + /// The stored `user_version` could not be decoded into a valid `Version` case. + case invalidStoredVersion(UInt32) + } + + // MARK: - Inits + + /// Creates a new user version storage instance. + public init() {} + + // MARK: - Methods + + /// Returns the current version stored in the `user_version` field. + /// + /// This method reads the `PRAGMA user_version` value and attempts to + /// decode it into a valid `Version` value. If the stored value is not + /// recognized, it throws an error. + /// + /// - Parameter connection: The database connection. + /// - Returns: A decoded version value of type `Version`. + /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value + /// cannot be mapped to a valid `Version` instance. + public func getVersion( + _ connection: Connection + ) throws -> Version { + let raw = UInt32(bitPattern: connection.userVersion) + guard let version = Version(rawValue: raw) else { + throw Error.invalidStoredVersion(raw) + } + return version + } + + /// Stores the given version in the `user_version` field. + /// + /// This method updates the `PRAGMA user_version` field + /// with the raw `UInt32` value of the provided `Version`. + /// + /// - Parameters: + /// - connection: The database connection. + /// - version: The version to store. + public func setVersion( + _ connection: Connection, + _ version: Version + ) throws { + connection.userVersion = .init( + bitPattern: version.rawValue + ) + } +} 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/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift new file mode 100644 index 0000000..840d4b5 --- /dev/null +++ b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift @@ -0,0 +1,53 @@ +import Foundation +import DataLiteCore + +/// A protocol for supplying encryption keys to `DatabaseService` instances. +/// +/// `DatabaseServiceKeyProvider` allows database services to delegate the responsibility of +/// retrieving, managing, and applying encryption keys. This enables separation of concerns +/// and allows for advanced strategies such as per-user key derivation, secure hardware-backed +/// storage, or biometric access control. +/// +/// When assigned to a `DatabaseService`, the provider is queried automatically whenever a +/// connection is created or re-established (e.g., during service initialization or reconnect). +/// +/// You can also implement error handling or diagnostics via the optional +/// ``databaseService(_:didReceive:)`` method. +/// +/// - Tip: You may throw from ``databaseServiceKey(_:)`` to indicate that the key is temporarily +/// unavailable or access is denied. +public protocol DatabaseServiceKeyProvider: AnyObject { + /// Returns the encryption key to be applied to the given database service. + /// + /// This method is invoked by the `DatabaseService` during initialization or reconnection + /// to retrieve the encryption key that should be applied to the new connection. + /// + /// Implementations may return a static key, derive it from metadata, or load it from + /// secure storage. If the key is unavailable (e.g., user not authenticated, system locked), + /// this method may throw to indicate failure. + /// + /// - Parameter service: The requesting database service. + /// - Returns: A `Connection.Key` representing the encryption key. + /// - Throws: Any error indicating that the key cannot be retrieved. + func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key + + /// Notifies the provider that the database service encountered an error while applying a key. + /// + /// This method is called when the service fails to retrieve or apply the encryption key. + /// You can use it to report diagnostics, attempt recovery, or update internal state. + /// + /// The default implementation is a no-op. + /// + /// - Parameters: + /// - service: The database service reporting the error. + /// - error: The error encountered during key retrieval or application. + func databaseService(_ service: DatabaseService, didReceive error: Error) +} + +public extension DatabaseServiceKeyProvider { + /// Default no-op implementation of error handling callback. + /// + /// This allows conforming types to ignore the error reporting mechanism + /// if they do not need to respond to key failures. + func databaseService(_ service: DatabaseService, didReceive error: Error) {} +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift new file mode 100644 index 0000000..ddd73a4 --- /dev/null +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -0,0 +1,55 @@ +import Foundation +import DataLiteCore + +/// A protocol that defines a common interface for working with a database connection. +/// +/// Conforming types provide methods for executing closures with a live `Connection`, optionally +/// wrapped in transactions. These closures are guaranteed to execute in a thread-safe and +/// serialized manner. Implementations may also support reconnecting and managing encryption keys. +public protocol DatabaseServiceProtocol: AnyObject { + /// A closure that performs a database operation using an active connection. + /// + /// The `Perform` alias defines the signature for a database operation block + /// that receives a live `Connection` and either returns a result or throws an error. + /// It is commonly used to express atomic units of work in ``perform(_:)`` or + /// ``perform(in:closure:)`` calls. + /// + /// - Parameter T: The result type returned by the closure. + /// - Returns: A value of type `T` produced by the closure. + /// - Throws: Any error that occurs during execution of the database operation. + typealias Perform = (Connection) throws -> T + + /// The object responsible for providing encryption keys for the database connection. + /// + /// When assigned, the key provider will be queried for a new key and applied to the current + /// connection, if available. + var keyProvider: DatabaseServiceKeyProvider? { get set } + + /// Re-establishes the database connection using the stored provider. + /// + /// If a `keyProvider` is set, the returned connection will attempt to apply a new key. + /// + /// - Throws: Any error that occurs during connection creation or key application. + func reconnect() throws + + /// Executes the given closure with a live connection. + /// + /// - Parameter closure: The operation to execute. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown during execution. + func perform(_ closure: Perform) rethrows -> T + + /// Executes the given closure within a transaction. + /// + /// If no transaction is active, a new one is started and committed or rolled back as needed. + /// + /// - Parameters: + /// - transaction: The transaction type to begin. + /// - closure: The operation to execute within the transaction. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown by the closure or transaction. + func perform( + in transaction: TransactionType, + closure: Perform + ) rethrows -> T +} diff --git a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift new file mode 100644 index 0000000..8422ccf --- /dev/null +++ b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift @@ -0,0 +1,17 @@ +import Foundation +import DataLiteCoder + +/// A protocol for database services that support row encoding and decoding. +/// +/// Conforming types provide `RowEncoder` and `RowDecoder` instances for serializing +/// and deserializing model types to and from SQLite row representations. +/// +/// This enables strongly typed, reusable, and safe access to database records +/// using Swift's `Codable` system. +public protocol RowDatabaseServiceProtocol: DatabaseServiceProtocol { + /// The encoder used to serialize values into database rows. + var encoder: RowEncoder { get } + + /// The decoder used to deserialize database rows into typed models. + var decoder: RowDecoder { get } +} diff --git a/Sources/DataRaft/Protocols/VersionRepresentable.swift b/Sources/DataRaft/Protocols/VersionRepresentable.swift new file mode 100644 index 0000000..8a276c8 --- /dev/null +++ b/Sources/DataRaft/Protocols/VersionRepresentable.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A constraint that defines the requirements for a type used as a database schema version. +/// +/// This type alias specifies the minimal set of capabilities a version type must have +/// to participate in schema migrations. Conforming types must be: +/// +/// - `Equatable`: to check whether two versions are equal +/// - `Comparable`: to compare versions and determine ordering +/// - `Hashable`: to use versions as dictionary keys or in sets +/// - `Sendable`: to ensure safe use in concurrent contexts +/// +/// Use this alias as a base constraint when defining custom version types +/// for use with ``VersionStorage``. +/// +/// ```swift +/// struct SemanticVersion: VersionRepresentable { +/// let major: Int +/// let minor: Int +/// let patch: Int +/// +/// static func < (lhs: Self, rhs: Self) -> Bool { +/// if lhs.major != rhs.major { +/// return lhs.major < rhs.major +/// } +/// if lhs.minor != rhs.minor { +/// return lhs.minor < rhs.minor +/// } +/// return lhs.patch < rhs.patch +/// } +/// } +/// ``` +public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable diff --git a/Sources/DataRaft/Protocols/VersionStorage.swift b/Sources/DataRaft/Protocols/VersionStorage.swift new file mode 100644 index 0000000..1bcfdd5 --- /dev/null +++ b/Sources/DataRaft/Protocols/VersionStorage.swift @@ -0,0 +1,140 @@ +import Foundation +import DataLiteCore + +/// A protocol that defines how the database version is stored and retrieved. +/// +/// This protocol decouples the concept of version representation from +/// the way the version is stored. It enables flexible implementations +/// that can store version values in different forms and places. +/// +/// The associated `Version` type determines how the version is represented +/// (e.g. as an integer, a semantic string, or a structured object), while the +/// conforming type defines how that version is persisted. +/// +/// Use this protocol to implement custom strategies for version tracking: +/// - Store an integer version in SQLite's `user_version` field. +/// - Store a string in a dedicated metadata table. +/// - Store structured data in a JSON column. +/// +/// To define your own versioning mechanism, implement `VersionStorage` +/// and choose a `Version` type that conforms to ``VersionRepresentable``. +/// +/// You can implement this protocol to define a custom way of storing the version +/// of a database schema. For example, the version could be a string stored in a metadata table. +/// +/// Below is an example of a simple implementation that stores the version string +/// in a table named `schema_version`. +/// +/// ```swift +/// final class StringVersionStorage: VersionStorage { +/// typealias Version = String +/// +/// func prepare(_ connection: Connection) throws { +/// let script: SQLScript = """ +/// 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: Connection) 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: Connection, _ 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() +/// } +/// } +/// ``` +/// +/// This implementation works as follows: +/// +/// - `prepare(_:)` creates the `schema_version` table if it does not exist, and ensures that it +/// contains exactly one row with an initial version value (`"0.0.0"`). +/// +/// - `getVersion(_:)` reads the current version string from the single row in the table. +/// If the row is missing, it throws an error. +/// +/// - `setVersion(_:_:)` updates the version string in that row. A `WHERE` clause is not necessary +/// because the table always contains exactly one row. +/// +/// ## Topics +/// +/// ### Associated Types +/// +/// - ``Version`` +/// +/// ### Instance Methods +/// +/// - ``prepare(_:)`` +/// - ``getVersion(_:)`` +/// - ``setVersion(_:_:)`` +public protocol VersionStorage { + /// A type representing the database schema version. + associatedtype Version: VersionRepresentable + + /// Prepares the storage mechanism for tracking the schema version. + /// + /// This method is called before any version operations. Use it to create required tables + /// or metadata structures needed for version management. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameter connection: The database connection used for schema preparation. + /// - Throws: An error if preparation fails. + func prepare(_ connection: Connection) throws + + /// Returns the current schema version stored in the database. + /// + /// This method must return a valid version previously stored by the migration system. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameter connection: The database connection used to fetch the version. + /// - Returns: The version currently stored in the database. + /// - Throws: An error if reading fails or the version is missing. + func getVersion(_ connection: Connection) throws -> Version + + /// Stores the given version as the current schema version. + /// + /// This method is called at the end of the migration process to persist + /// the final schema version after all migration steps have completed successfully. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameters: + /// - connection: The database connection used to write the version. + /// - version: The version to store. + /// - Throws: An error if writing fails. + func setVersion(_ connection: Connection, _ version: Version) throws +} + +public extension VersionStorage { + /// A default implementation that performs no preparation. + /// + /// Override this method if your storage implementation requires any setup, + /// such as creating a version table or inserting an initial value. + /// + /// If you override this method and it throws an error, the migration process + /// will be aborted and rolled back. + func prepare(_ connection: Connection) throws {} +} diff --git a/Sources/DataRaft/Structures/BitPackVersion.swift b/Sources/DataRaft/Structures/BitPackVersion.swift new file mode 100644 index 0000000..f74add6 --- /dev/null +++ b/Sources/DataRaft/Structures/BitPackVersion.swift @@ -0,0 +1,158 @@ +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, *) +@available(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..12d40da --- /dev/null +++ b/Sources/DataRaft/Structures/Migration.swift @@ -0,0 +1,75 @@ +import Foundation +import DataLiteCore + +/// Represents a database migration step associated with a specific version. +/// +/// Each `Migration` contains a reference to a migration script file (usually a `.sql` file) and the +/// version to which this script corresponds. The script is expected to be bundled with the application. +/// +/// You can initialize a migration directly with a URL to the script, or load it from a resource +/// embedded in a bundle. +public struct Migration: Hashable, Sendable { + // MARK: - Properties + + /// The version associated with this migration step. + public let version: Version + + /// The URL pointing to the migration script (e.g., an SQL file). + public let scriptURL: URL + + /// The SQL script associated with this migration. + /// + /// This computed property reads the contents of the file at `scriptURL` and returns it as a + /// `SQLScript` instance. Use this to access and execute the migration's SQL commands. + /// + /// - Throws: An error if the script file cannot be read or is invalid. + public var script: SQLScript { + get throws { + try SQLScript(contentsOf: scriptURL) + } + } + + // MARK: - Inits + + /// Creates a migration with a specified version and script URL. + /// + /// - Parameters: + /// - version: The version this migration corresponds to. + /// - scriptURL: The file URL to the migration script. + public init(version: Version, scriptURL: URL) { + self.version = version + self.scriptURL = scriptURL + } + + /// Creates a migration by locating a script resource in the specified bundle. + /// + /// This initializer attempts to locate a script file in the provided bundle using the specified + /// resource `name` and optional `extension`. The `name` parameter may include or omit the file extension. + /// + /// - If `name` includes an extension (e.g., `"001_init.sql"`), pass `extension` as `nil` or an empty string. + /// - If `name` omits the extension (e.g., `"001_init"`), specify the extension separately + /// (e.g., `"sql"`), or leave it `nil` if the file has no extension. + /// + /// - Important: Passing a name that already includes the extension while also specifying a non-`nil` + /// `extension` may result in failure to locate the file. + /// + /// - Parameters: + /// - version: The version this migration corresponds to. + /// - name: The resource name of the script file. May include or omit the file extension. + /// - extension: The file extension, if separated from the name. Defaults to `nil`. + /// - bundle: The bundle in which to search for the resource. Defaults to `.main`. + /// + /// - Returns: A `Migration` if the resource file 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..ce04436 --- /dev/null +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +import DataLiteC +import DataLiteCore +import DataRaft + +class DatabaseServiceTests: DatabaseServiceKeyProvider { + 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 = try 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 databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key { + currentKey + } +} + +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) + try connection.rekey(keyTwo) + 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) + try connection.rekey(keyTwo) + let error = Connection.Error( + 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() + } + }) + currentKey = keyTwo + try service.reconnect() + 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) + } + } +} diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift new file mode 100644 index 0000000..9f145e6 --- /dev/null +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -0,0 +1,101 @@ +import Testing +import DataLiteCore +@testable import DataRaft + +@Suite struct MigrationServiceTests { + private typealias MigrationService = DataRaft.MigrationService + + private var connection: Connection! + private var migrationService: MigrationService! + + init() throws { + let connection = try Connection(location: .inMemory, options: .readwrite) + self.connection = connection + self.migrationService = .init(service: .init(connection: connection), storage: .init()) + } + + @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 MigrationService.Error.duplicateMigration(let migration) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func migrate() 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 migrationService.migrate() + + #expect(connection.userVersion == 2) + } + + @Test func migrateWithError() 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 migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration3.version)") + } catch MigrationService.Error.migrationFailed(let migration, _) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(connection.userVersion == 0) + } + + @Test func migrateWithEmptyMigration() throws { + let migration1 = Migration(version: 1, byResource: "migration_1", extension: "sql", in: .module)! + let migration2 = Migration(version: 2, byResource: "migration_2", extension: "sql", in: .module)! + let migration4 = Migration(version: 4, byResource: "migration_4", extension: "sql", in: .module)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try migrationService.add(migration4) + + do { + try migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration4.version)") + } catch MigrationService.Error.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: Connection) throws -> Version { + connection.userVersion + } + + func setVersion(_ connection: Connection, _ 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/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/Resources/migration_4.sql b/Tests/DataRaftTests/Resources/migration_4.sql new file mode 100644 index 0000000..af447e5 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_4.sql @@ -0,0 +1 @@ +-- Empty Script 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..17d113b --- /dev/null +++ b/Tests/DataRaftTests/Structures/MigrationTests.swift @@ -0,0 +1,69 @@ +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 bundle = Bundle.module // или другой, если тестовая ресурсная цель другая + let version = DummyVersion(rawValue: 2) + + let migration = Migration( + version: version, + byResource: "migration_1", + extension: "sql", + in: bundle + ) + + #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 + } + } +}