Compare commits

13 Commits

27 changed files with 2308 additions and 0 deletions

42
Package.swift Normal file
View File

@@ -0,0 +1,42 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "DataRaft",
platforms: [
.macOS(.v10_14),
.iOS(.v12)
],
products: [
.library(
name: "DataRaft",
targets: ["DataRaft"]
)
],
dependencies: [
.package(url: "https://github.com/angd-dev/data-lite-core.git", .upToNextMinor(from: "1.1.0")),
.package(url: "https://github.com/angd-dev/data-lite-coder.git", .upToNextMinor(from: "1.0.0")),
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0")
],
targets: [
.target(
name: "DataRaft",
dependencies: [
.product(name: "DataLiteCore", package: "data-lite-core"),
.product(name: "DataLiteCoder", package: "data-lite-coder")
]
),
.testTarget(
name: "DataRaftTests",
dependencies: ["DataRaft"],
resources: [
.copy("Resources/empty.sql"),
.copy("Resources/migration_1.sql"),
.copy("Resources/migration_2.sql"),
.copy("Resources/migration_3.sql")
]
)
]
)

View File

@@ -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.

View File

@@ -0,0 +1,35 @@
import Foundation
/// A type that describes a database schema version.
///
/// ## Overview
///
/// Types conforming to this alias can be compared, checked for equality, hashed, and safely used
/// across concurrent contexts. Such types are typically used to track and manage schema migrations.
///
/// ## Conformance
///
/// Conforming types must implement:
/// - `Equatable` for equality checks
/// - `Comparable` for ordering versions
/// - `Hashable` for dictionary/set membership
/// - `Sendable` for concurrency safety
///
/// ## Usage
///
/// Use this type alias when defining custom version types for use with ``VersionStorage``.
///
/// ```swift
/// struct SemanticVersion: VersionRepresentable {
/// let major: Int
/// let minor: Int
/// let patch: Int
///
/// static func < (lhs: Self, rhs: Self) -> Bool {
/// if lhs.major != rhs.major { return lhs.major < rhs.major }
/// if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
/// return lhs.patch < rhs.patch
/// }
/// }
/// ```
public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable

View File

@@ -0,0 +1,204 @@
import Foundation
import DataLiteCore
/// A base service responsible for establishing and maintaining a database connection.
///
/// ## Overview
///
/// `ConnectionService` provides a managed execution environment for database operations. It handles
/// connection creation, configuration, encryption key application, and optional reconnection based
/// on the associated key providers policy.
///
/// This class guarantees thread-safe access by executing all operations within a dedicated dispatch
/// queue. Subclasses may extend it with additional behaviors, such as transaction management or
/// lifecycle event posting.
///
/// ## Topics
///
/// ### Creating a Service
///
/// - ``ConnectionProvider``
/// - ``ConnectionConfig``
/// - ``init(provider:config:queue:)``
/// - ``init(connection:config:queue:)``
///
/// ### Key Management
///
/// - ``ConnectionServiceKeyProvider``
/// - ``keyProvider``
///
/// ### Connection Lifecycle
///
/// - ``setNeedsReconnect()``
///
/// ### Performing Operations
///
/// - ``ConnectionServiceProtocol/Perform``
/// - ``perform(_:)``
open class ConnectionService:
ConnectionServiceProtocol,
@unchecked Sendable
{
// MARK: - Typealiases
/// A closure that creates a new database connection.
///
/// Used for deferred connection creation. Encapsulates initialization logic, configuration, and
/// error handling when opening the database.
///
/// - Returns: An initialized connection instance.
/// - Throws: An error if the connection cannot be created or configured.
public typealias ConnectionProvider = () throws -> ConnectionProtocol
/// A closure that configures a newly created connection.
///
/// Called after the connection is established and, if applicable, after the encryption key has
/// been applied. Use this closure to set PRAGMA options or perform additional initialization
/// logic.
///
/// - Parameter connection: The newly created connection to configure.
/// - Throws: An error if configuration fails.
public typealias ConnectionConfig = (ConnectionProtocol) throws -> Void
// MARK: - Properties
private let provider: ConnectionProvider
private let config: ConnectionConfig?
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private var shouldReconnect: Bool {
keyProvider?.connectionService(shouldReconnect: self) ?? false
}
private var needsReconnect: Bool = false
private var cachedConnection: ConnectionProtocol?
private var connection: ConnectionProtocol {
get throws {
guard let cachedConnection, !needsReconnect else {
let connection = try connect()
cachedConnection = connection
needsReconnect = false
return connection
}
return cachedConnection
}
}
/// The provider responsible for supplying encryption keys to the service.
///
/// The key provider may determine whether reconnection is allowed and supply
/// the encryption key when the connection is established or restored.
public weak var keyProvider: ConnectionServiceKeyProvider?
// MARK: - Inits
/// Creates a new connection service.
///
/// Configures an internal serial queue for thread-safe access to the database. The connection
/// itself is not created during initialization it is established lazily on first use (for
/// example, inside ``perform(_:)``).
///
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
/// target of the internal queue.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal one.
public required init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.provider = provider
self.config = config
self.queue = .init(for: Self.self, qos: .utility)
self.queue.setSpecific(key: queueKey, value: ())
if let queue = queue {
self.queue.setTarget(queue: queue)
}
}
/// Creates a new connection service using an autoclosure-based provider.
///
/// This initializer provides a convenient way to wrap an existing connection expression in an
/// autoclosure. The connection itself is not created during initialization it is established
/// lazily on first use.
///
/// - Parameters:
/// - provider: An autoclosure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal one.
public required convenience init(
connection provider: @escaping @autoclosure ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.init(provider: provider, config: config, queue: queue)
}
// MARK: - Connection Lifecycle
/// Marks the service as requiring reconnection before the next operation.
///
/// The reconnection behavior depends on the key providers 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 services internal queue and ensures that the connection is valid
/// before use. If the connection is unavailable or fails during execution, this method throws
/// an error.
///
/// - Parameter closure: The operation to perform using the connection.
/// - Returns: The result produced by the closure.
/// - Throws: An error thrown by the closure or the connection.
public func perform<T>(_ closure: Perform<T>) throws -> T {
switch DispatchQueue.getSpecific(key: queueKey) {
case .none: try queue.sync { try closure(connection) }
case .some: try closure(connection)
}
}
// MARK: - Internal Methods
func connect() throws -> ConnectionProtocol {
let connection = try provider()
try applyKey(to: connection)
try config?(connection)
return connection
}
func applyKey(to connection: ConnectionProtocol) throws {
guard let keyProvider = keyProvider else { return }
do {
let key = try keyProvider.connectionService(keyFor: self)
let sql = "SELECT count(*) FROM sqlite_master"
try connection.apply(key, name: nil)
try connection.execute(sql: sql)
} catch {
keyProvider.connectionService(self, didReceive: error)
throw error
}
}
}

View File

@@ -0,0 +1,261 @@
import Foundation
import DataLiteCore
import DataLiteC
/// A base database service that handles transactions and posts change notifications.
///
/// ## Overview
///
/// `DatabaseService` provides a lightweight transactional layer for performing database operations
/// within a thread-safe execution context. It automatically detects modifications to the database
/// and posts a ``databaseDidChange`` notification database updates, allowing observers to react to
/// updates.
///
/// This class is intended to be subclassed by higher-level data managers that encapsulate domain
/// logic while relying on consistent connection and transaction handling.
///
/// ## Usage
///
/// ```swift
/// final class NoteService: DatabaseService {
/// func insertNote(_ text: String) throws {
/// try perform(in: .deferred) { connection in
/// let sql = "INSERT INTO notes (text) VALUES (?)"
/// let stmt = try connection.prepare(sql: sql)
/// try stmt.bind(text, at: 0)
/// try stmt.step()
/// }
/// }
/// }
///
/// let connection = try Connection(location: .inMemory, options: [])
/// let service = NoteService(connection: connection)
/// try service.insertNote("Hello, world!")
/// ```
///
/// ## Topics
///
/// ### Initializers
///
/// - ``ConnectionService/ConnectionProvider``
/// - ``ConnectionService/ConnectionConfig``
/// - ``init(provider:config:queue:center:)``
/// - ``init(provider:config:queue:)``
///
/// ### Performing Operations
///
/// - ``ConnectionServiceProtocol/Perform``
/// - ``perform(_:)``
/// - ``perform(in:closure:)``
///
/// ### Connection Delegate
///
/// - ``connection(_:didUpdate:)``
/// - ``connectionWillCommit(_:)``
/// - ``connectionDidRollback(_:)``
///
/// ### Notifications
///
/// - ``databaseDidChange``
open class DatabaseService:
ConnectionService,
DatabaseServiceProtocol,
ConnectionDelegate,
@unchecked Sendable
{
// MARK: - Properties
private let center: NotificationCenter?
/// Notification posted after the database content changes with this service.
public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange")
// MARK: - Inits
/// Creates a database service with a specified notification center.
///
/// Configures an internal serial queue for thread-safe access to the database. The connection
/// itself is not created during initialization it is established lazily on first use (for
/// example, inside ``perform(_:)``).
///
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
/// target of the internal queue.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal one.
/// - center: An optional notification center used to post database change notifications.
public init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil,
center: NotificationCenter?
) {
self.center = center
super.init(provider: provider, config: config, queue: queue)
}
/// Creates a database service using the default database notification center.
///
/// Configures an internal serial queue for thread-safe access to the database. The connection
/// itself is not created during initialization it is established lazily on first use (for
/// example, inside ``perform(_:)``).
///
/// The service posts change notifications through ``Foundation/NotificationCenter/databaseCenter``,
/// which provides a shared channel for observing database events across the application.
///
/// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the
/// target of the internal queue.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal one.
public required init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.center = .databaseCenter
super.init(provider: provider, config: config, queue: queue)
}
// MARK: - Performing Operations
/// Executes a closure within the context of a managed database connection.
///
/// Runs the operation on the services internal queue and ensures that the connection is valid
/// before use. If the connection is unavailable or fails during execution, this method throws
/// an error.
///
/// After the closure completes, if the database content has changed, the service posts a
/// ``databaseDidChange`` notification through its configured notification center.
///
/// - Parameter closure: The operation to perform using the connection.
/// - Returns: The result produced by the closure.
/// - Throws: An error thrown by the closure or the connection.
public override func perform<T>(_ closure: Perform<T>) throws -> T {
try super.perform { connection in
let changes = connection.totalChanges
defer {
if changes != connection.totalChanges {
center?.post(name: Self.databaseDidChange, object: self)
}
}
return try closure(connection)
}
}
/// 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<T>(
in transaction: TransactionType,
closure: Perform<T>
) 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
}
}

View File

@@ -0,0 +1,229 @@
import Foundation
import DataLiteCore
#if os(Windows)
import WinSDK
#endif
/// A service that executes ordered database schema migrations.
///
/// ## Overview
///
/// This class manages migration registration and applies them sequentially to update the database
/// schema. Each migration corresponds to a specific version and runs only once, ensuring that
/// schema upgrades are applied in a consistent, deterministic way.
///
/// Migrations are executed within an exclusive transaction if any step fails, the entire process
/// is rolled back, leaving the database unchanged.
///
/// `MigrationService` coordinates the migration process by:
/// - Managing a registry of unique migrations.
/// - Reading and writing the current schema version through a ``VersionStorage`` implementation.
/// - Executing SQL scripts in ascending version order.
///
/// It is safe for concurrent use. Internally, it uses a POSIX mutex to ensure thread-safe
/// registration and execution.
///
/// ## Usage
///
/// ```swift
/// let connection = try Connection(location: .inMemory, options: .readwrite)
/// let storage = UserVersionStorage<SemanticVersion>()
/// 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<Migration<Version>>()
#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<T>(_ closure: Perform<T>) throws -> T {
fatalError("Direct perform is not supported for MigrationService.")
}
// MARK: - Migration Management
/// Registers a new migration, ensuring version and script URL uniqueness.
///
/// - 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<Version>) throws(MigrationError<Version>) {
#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<Version>) {
#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<Version> {
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)
}
}
}

View File

@@ -0,0 +1,138 @@
import Foundation
import DataLiteCoder
/// A database service that provides model encoding and decoding support.
///
/// ## Overview
///
/// `ModelDatabaseService` extends ``DatabaseService`` by integrating `RowEncoder` and `RowDecoder`
/// to simplify model-based interactions with the database. Subclasses can encode Swift types into
/// SQLite rows and decode query results back into strongly typed models.
///
/// This enables a clean, type-safe persistence layer for applications that use Codable or custom
/// encodable/decodable types.
///
/// `ModelDatabaseService` serves as a foundation for higher-level model repositories and services.
/// It inherits all transactional and thread-safe behavior from ``DatabaseService`` while adding
/// automatic model serialization.
///
/// ## Usage
///
/// ```swift
/// struct User: Codable {
/// let id: Int
/// let name: String
/// }
///
/// final class UserService: ModelDatabaseService, @unchecked Sendable {
/// func fetchUser() throws -> User? {
/// try perform(in: .deferred) { connection in
/// let stmt = try connection.prepare(sql: "SELECT * FROM users")
/// guard try stmt.step(), let row = stmt.currentRow() else {
/// return nil
/// }
/// return try decoder.decode(User.self, from: row)
/// }
/// }
///
/// func insertUser(_ user: User) throws {
/// try perform(in: .immediate) { connection in
/// let row = try encoder.encode(user)
/// let columns = row.columns.joined(separator: ", ")
/// let placeholders = row.namedParameters.joined(separator: ", ")
/// let sql = "INSERT INTO users (\(columns)) VALUES (\(placeholders))"
/// let stmt = try connection.prepare(sql: sql)
/// try stmt.execute([row])
/// }
/// }
/// }
/// ```
///
/// ## Topics
///
/// ### Properties
///
/// - ``encoder``
/// - ``decoder``
///
/// ### Initializers
///
/// - ``init(provider:config:queue:center:encoder:decoder:)``
/// - ``init(provider:config:queue:)``
/// - ``init(connection:config:queue:)``
open class ModelDatabaseService: DatabaseService, @unchecked Sendable {
// MARK: - Properties
/// The encoder used to serialize models into row representations.
public let encoder: RowEncoder
/// The decoder used to deserialize database rows into model instances.
public let decoder: RowDecoder
// MARK: - Inits
/// Creates a model-aware database service.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: Optional configuration for the connection.
/// - queue: The dispatch queue used for serializing database operations.
/// - center: The notification center used for database events. Defaults to `.databaseCenter`.
/// - encoder: The encoder for converting models into SQLite rows.
/// - decoder: The decoder for converting rows back into model instances.
public init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil,
center: NotificationCenter = .databaseCenter,
encoder: RowEncoder,
decoder: RowDecoder
) {
self.encoder = encoder
self.decoder = decoder
super.init(
provider: provider,
config: config,
queue: queue,
center: center
)
}
/// Creates a model-aware database service using default encoder and decoder instances.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: Optional configuration for the connection.
/// - queue: The dispatch queue used for serializing database operations.
public required init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.encoder = .init()
self.decoder = .init()
super.init(
provider: provider,
config: config,
queue: queue
)
}
/// Creates a model-aware database service from a connection autoclosure.
///
/// - Parameters:
/// - provider: A connection autoclosure that returns a database connection.
/// - config: Optional configuration for the connection.
/// - queue: The dispatch queue used for serializing database operations.
public required convenience init(
connection provider: @escaping @autoclosure ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.init(
provider: provider,
config: config,
queue: queue
)
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
import DataLiteCore
/// A version storage that persists schema versions in SQLites `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 applications 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)
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import DataLiteCore
/// Errors that can occur during database migration registration or execution.
///
/// ## Overview
///
/// These errors indicate problems such as duplicate migrations, failed execution, or empty
/// migration scripts.
///
/// ## Topics
///
/// ### Error Cases
/// - ``duplicateMigration(_:)``
/// - ``emptyMigrationScript(_:)``
/// - ``migrationFailed(_:_:)``
public enum MigrationError<Version: VersionRepresentable>: 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<Version>)
/// Indicates that the migration script is empty.
///
/// - Parameter migration: The migration whose script is empty.
case emptyMigrationScript(Migration<Version>)
/// Indicates that migration execution failed.
///
/// - Parameters:
/// - migration: The migration that failed, if available.
/// - error: The underlying error that caused the failure.
case migrationFailed(Migration<Version>?, Error)
}

View File

@@ -0,0 +1,19 @@
import Foundation
extension DispatchQueue {
convenience init<T>(
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
)
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
public extension NotificationCenter {
/// The notification center dedicated to database events.
///
/// Use this instance to post and observe notifications related to database lifecycle and
/// operations instead of using the shared `NotificationCenter.default`.
static let databaseCenter = NotificationCenter()
}

View File

@@ -0,0 +1,45 @@
import Foundation
import DataLiteCore
/// A type that provides encryption keys to a database connection service.
///
/// ## Overview
///
/// This type manages how encryption keys are obtained and applied when establishing or restoring a
/// connection. Implementations can use static, dynamic, hardware-backed, or biometric key sources.
///
/// - The service requests a key when establishing or restoring a connection.
/// - If decryption fails, the service may ask whether it should attempt to reconnect.
/// - If applying a key fails (for example, the key is invalid or ``connectionService(keyFor:)``
/// throws), the error is reported through ``connectionService(_:didReceive:)``.
///
/// - Important: The provider does not receive general database errors.
///
/// ## Topics
///
/// ### Providing Keys and Handling Errors
///
/// - ``connectionService(keyFor:)``
/// - ``connectionService(shouldReconnect:)``
/// - ``connectionService(_:didReceive:)``
public protocol ConnectionServiceKeyProvider: AnyObject, Sendable {
/// Returns the encryption key for the specified database service.
///
/// - Parameter service: The service requesting the key.
/// - Returns: The encryption key.
/// - Throws: An error if the key cannot be retrieved.
func connectionService(keyFor service: ConnectionServiceProtocol) throws -> Connection.Key
/// Indicates whether the service should attempt to reconnect if applying the key fails.
///
/// - Parameter service: The database service.
/// - Returns: `true` to attempt reconnection. Defaults to `false`.
func connectionService(shouldReconnect service: ConnectionServiceProtocol) -> Bool
/// Notifies the provider of an error that occurred during key retrieval or application.
///
/// - Parameters:
/// - service: The database service reporting the error.
/// - error: The error encountered during key retrieval or application.
func connectionService(_ service: ConnectionServiceProtocol, didReceive error: Error)
}

View File

@@ -0,0 +1,56 @@
import Foundation
import DataLiteCore
/// A type that manages the lifecycle of a database connection.
///
/// ## Overview
///
/// Conforming types implement the mechanisms required to open, configure, reconnect, and safelyuse
/// a database connection across multiple threads or tasks. This abstraction allows higher-level
/// services to execute operations without dealing with low-level connection handling.
///
/// ## Topics
///
/// ### Key Management
///
/// - ``ConnectionServiceKeyProvider``
/// - ``keyProvider``
///
/// ### Connection Lifecycle
///
/// - ``setNeedsReconnect()``
///
/// ### Performing Operations
///
/// - ``Perform``
/// - ``perform(_:)``
public protocol ConnectionServiceProtocol: AnyObject, Sendable {
/// A closure type that performs an operation using an active database connection.
///
/// - Parameter connection: The active database connection used for the operation.
/// - Returns: The result produced by the closure.
/// - Throws: Any error thrown by the closure or connection layer.
typealias Perform<T> = (ConnectionProtocol) throws -> T
/// The provider responsible for supplying encryption keys to the service.
var keyProvider: ConnectionServiceKeyProvider? { get set }
/// Marks the service as requiring reconnection before the next operation.
///
/// The reconnection behavior depends on the key providers implementation of
/// ``ConnectionServiceKeyProvider/connectionService(shouldReconnect:)``.
///
/// - Returns: `true` if the reconnection flag was set; otherwise, `false`.
@discardableResult
func setNeedsReconnect() -> Bool
/// Executes a closure within the context of an active database connection.
///
/// Implementations ensure that a valid connection is available before executing the operation.
/// If the connection is not available or fails, this method throws an error.
///
/// - Parameter closure: The operation to perform using the connection.
/// - Returns: The result produced by the closure.
/// - Throws: Any error thrown by the closure or the underlying connection.
func perform<T>(_ closure: Perform<T>) throws -> T
}

View File

@@ -0,0 +1,45 @@
import Foundation
import DataLiteCore
/// A type that extends connection management with transactional database operations.
///
/// ## Overview
///
/// This type builds on ``ConnectionServiceProtocol`` by adding the ability to execute closures
/// within explicit transactions. Conforming types manage transaction boundaries and ensure that all
/// operations within a transaction are committed or rolled back consistently.
///
/// ## Topics
///
/// ### Performing Operations
///
/// - ``ConnectionServiceProtocol/Perform``
/// - ``perform(in:closure:)``
public protocol DatabaseServiceProtocol: ConnectionServiceProtocol {
/// Executes a closure inside a transaction if the connection is in autocommit mode.
///
/// If the connection operates in autocommit mode, this method starts a new transaction of the
/// specified type, executes the closure, and commits the changes on success. If the closure
/// throws an error, the transaction is rolled back.
///
/// Implementations may attempt to re-establish the connection and reapply the encryption key if
/// an error indicates a lost or invalid database state (for example, `SQLiteError` with code
/// `SQLITE_NOTADB`). In such cases, the service can retry the transaction block once after a
/// successful reconnection. If reconnection fails or is disallowed by the key provider, the
/// original error is propagated.
///
/// If a transaction is already active, the closure is executed directly without starting a new
/// transaction.
///
/// - Parameters:
/// - transaction: The type of transaction to start.
/// - closure: A closure that takes the active connection and returns a result.
/// - Returns: The value returned by the closure.
/// - Throws: Errors from connection creation, key application, configuration, transaction
/// management, or from the closure itself.
///
/// - Important: The closure may be executed more than once if a reconnection occurs. Ensure it
/// performs only database operations and does not produce external side effects (such as
/// sending network requests or posting notifications).
func perform<T>(in transaction: TransactionType, closure: Perform<T>) throws -> T
}

View File

@@ -0,0 +1,61 @@
import Foundation
/// A type that manages and executes database schema migrations.
///
/// ## Overview
///
/// Conforming types are responsible for registering migration steps, applying encryption keys
/// (if required), and executing pending migrations in ascending version order. Migrations ensure
/// that the database schema evolves consistently across application versions without manual
/// intervention.
///
/// ## Topics
///
/// ### Associated Types
/// - ``Version``
///
/// ### Properties
/// - ``keyProvider``
///
/// ### Instance Methods
/// - ``add(_:)``
/// - ``migrate()``
/// - ``migrate()-18x5r``
public protocol MigrationServiceProtocol: AnyObject, Sendable {
/// The type representing a schema version used for migrations.
associatedtype Version: VersionRepresentable
/// The provider responsible for supplying encryption keys to the service.
var keyProvider: ConnectionServiceKeyProvider? { get set }
/// Registers a migration to be executed by the service.
///
/// - Parameter migration: The migration to register.
/// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version
/// or script URL is already registered.
func add(_ migration: Migration<Version>) throws(MigrationError<Version>)
/// 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<Version>)
}
@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
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
import DataLiteCore
/// A type that defines how a database schema version is stored and retrieved.
///
/// ## Overview
///
/// This protocol separates the concept of version representation from its persistence mechanism,
/// allowing flexible implementations that store version values in different formats or locations.
///
/// The associated ``Version`` type specifies how the version is represented (for example, as an
/// integer, a semantic string, or a structured object), while the conforming type defines how that
/// version is persisted.
///
/// ## Usage
///
/// Implement this type to define a custom strategy for schema version tracking:
/// - Store an integer version in SQLites `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 {}
}

View File

@@ -0,0 +1,157 @@
import Foundation
/// A semantic version packed into a 32-bit unsigned integer.
///
/// This type stores a `major.minor.patch` version using bit fields inside a single `UInt32`:
///
/// - 12 bits for `major` in the 0...4095 range
/// - 12 bits for `minor`in the 0...4095 range
/// - 8 bits for `patch` in the 0...255 range
///
/// ## Topics
///
/// ### Errors
///
/// - ``Error``
/// - ``ParseError``
///
/// ### Creating a Version
///
/// - ``init(rawValue:)``
/// - ``init(major:minor:patch:)``
/// - ``init(version:)``
/// - ``init(stringLiteral:)``
///
/// ### Instance Properties
///
/// - ``rawValue``
/// - ``major``
/// - ``minor``
/// - ``patch``
/// - ``description``
public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStringConvertible {
/// An error related to invalid version components.
public enum Error: Swift.Error {
/// An error for a major component that exceeds the allowed range.
case majorOverflow(UInt32)
/// An error for a minor component that exceeds the allowed range.
case minorOverflow(UInt32)
/// An error for a patch component that exceeds the allowed range.
case patchOverflow(UInt32)
/// A message describing the reason for the error.
public var localizedDescription: String {
switch self {
case .majorOverflow(let value):
"Major version overflow: \(value). Allowed range: 0...4095."
case .minorOverflow(let value):
"Minor version overflow: \(value). Allowed range: 0...4095."
case .patchOverflow(let value):
"Patch version overflow: \(value). Allowed range: 0...255."
}
}
}
// MARK: - Properties
/// The packed 32-bit value that encodes the version.
public let rawValue: UInt32
/// The major component of the version.
public var major: UInt32 { (rawValue >> 20) & 0xFFF }
/// The minor component of the version.
public var minor: UInt32 { (rawValue >> 8) & 0xFFF }
/// The patch component of the version.
public var patch: UInt32 { rawValue & 0xFF }
/// A string representation in the form `"major.minor.patch"`.
public var description: String {
"\(major).\(minor).\(patch)"
}
// MARK: - Inits
/// Creates a version from a packed 32-bit unsigned integer.
///
/// - Parameter rawValue: A bit-packed version value.
public init(rawValue: UInt32) {
self.rawValue = rawValue
}
/// Creates a version from individual components.
///
/// - Parameters:
/// - major: The major component in the 0...4095 range.
/// - minor: The minor component in the 0...4095 range.
/// - patch: The patch component in the 0...255 range. Defaults to `0`.
///
/// - Throws: ``Error/majorOverflow(_:)`` if `major` is out of range.
/// - Throws: ``Error/minorOverflow(_:)`` if `minor` is out of range.
/// - Throws: ``Error/patchOverflow(_:)`` if `patch` is out of range.
public init(major: UInt32, minor: UInt32, patch: UInt32 = 0) throws {
guard major < (1 << 12) else { throw Error.majorOverflow(major) }
guard minor < (1 << 12) else { throw Error.minorOverflow(minor) }
guard patch < (1 << 8) else { throw Error.patchOverflow(patch) }
self.init(rawValue: (major << 20) | (minor << 8) | patch)
}
// MARK: - Comparable
/// Compares two versions by their packed 32-bit values.
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
// MARK: - ExpressibleByStringLiteral
@available(iOS 16.0, macOS 13.0, *)
extension BitPackVersion: ExpressibleByStringLiteral {
/// An error related to parsing a version string.
public enum ParseError: Swift.Error {
/// A string that doesn't match the expected version format.
case invalidFormat(String)
/// A message describing the format issue.
public var localizedDescription: String {
switch self {
case .invalidFormat(let str):
"Invalid version format: \(str). Expected something like '1.2' or '1.2.3'."
}
}
}
/// Creates a version by parsing a string like `"1.2"` or `"1.2.3"`.
///
/// - Parameter version: A version string in the form `x.y` or `x.y.z`.
///
/// - Throws: ``ParseError/invalidFormat(_:)`` if the string format is invalid.
/// - Throws: `Error` if any component is out of range.
public init(version: String) throws {
let regex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/
guard version.wholeMatch(of: regex) != nil else {
throw ParseError.invalidFormat(version)
}
let parts = version.split(separator: ".")
.compactMap { UInt32($0) }
try self.init(
major: parts[0],
minor: parts[1],
patch: parts.count == 3 ? parts[2] : 0
)
}
/// Creates a version from a string literal like `"1.2"` or `"1.2.3"`.
///
/// - Warning: Crashes if the string format is invalid.
/// Use ``init(version:)`` for safe parsing.
public init(stringLiteral value: String) {
try! self.init(version: value)
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
/// A database migration step for a specific schema version.
///
/// ## Overview
///
/// Each migration links a version identifier with a script file that modifies the database schema.
/// Scripts are typically bundled with the application and executed sequentially during version
/// upgrades.
///
/// ## Topics
///
/// ### Properties
/// - ``version``
/// - ``scriptURL``
/// - ``script``
///
/// ### Initializers
/// - ``init(version:scriptURL:)``
/// - ``init(version:byResource:extension:in:)``
public struct Migration<Version: VersionRepresentable>: 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)
}
}

View File

@@ -0,0 +1,197 @@
import Foundation
import Testing
import DataLiteC
import DataLiteCore
import DataRaft
class DatabaseServiceTests: ConnectionServiceKeyProvider, @unchecked Sendable {
private let keyOne = Connection.Key.rawKey(Data([
0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0,
0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a,
0xa2, 0xd7, 0x78, 0xe9, 0xe5, 0x87, 0x05, 0xb4,
0xe2, 0x1a, 0x42, 0x74, 0xee, 0xbc, 0x4c, 0x06
]))
private let keyTwo = Connection.Key.rawKey(Data([
0x9f, 0x45, 0x23, 0xbf, 0xfe, 0x11, 0x3e, 0x79,
0x42, 0x21, 0x48, 0x7c, 0xb6, 0xb1, 0xd5, 0x09,
0x34, 0x5f, 0xcb, 0x53, 0xa3, 0xdd, 0x8e, 0x41,
0x95, 0x27, 0xbb, 0x4e, 0x6e, 0xd8, 0xa7, 0x05
]))
private let fileURL: URL
private let service: DatabaseService
private lazy var currentKey = keyOne
init() throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("sqlite")
let service = DatabaseService(provider: {
try Connection(
path: fileURL.path,
options: [.create, .readwrite]
)
})
self.fileURL = fileURL
self.service = service
self.service.keyProvider = self
try self.service.perform { connection in
try connection.execute(sql: """
CREATE TABLE IF NOT EXISTS Item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)
""")
}
}
deinit {
try? FileManager.default.removeItem(at: fileURL)
}
func connectionService(keyFor service: any ConnectionServiceProtocol) throws -> Connection.Key {
currentKey
}
func connectionService(shouldReconnect service: any ConnectionServiceProtocol) -> Bool {
true
}
func connectionService(_ service: any ConnectionServiceProtocol, didReceive error: any Error) {
}
}
extension DatabaseServiceTests {
@Test func testSuccessPerformTransaction() throws {
try service.perform(in: .deferred) { connection in
#expect(connection.isAutocommit == false)
let stmt = try connection.prepare(
sql: "INSERT INTO Item (name) VALUES (?)",
options: []
)
try stmt.bind("Book", at: 1)
try stmt.step()
}
try service.perform { connection in
let stmt = try connection.prepare(
sql: "SELECT COUNT(*) FROM Item",
options: []
)
try stmt.step()
#expect(connection.isAutocommit)
#expect(stmt.columnValue(at: 0) == 1)
}
}
@Test func testNestedPerformTransaction() throws {
try service.perform(in: .deferred) { _ in
try service.perform(in: .deferred) { connection in
#expect(connection.isAutocommit == false)
let stmt = try connection.prepare(
sql: "INSERT INTO Item (name) VALUES (?)",
options: []
)
try stmt.bind("Book", at: 1)
try stmt.step()
}
}
try service.perform { connection in
let stmt = try connection.prepare(
sql: "SELECT COUNT(*) FROM Item",
options: []
)
try stmt.step()
#expect(connection.isAutocommit)
#expect(stmt.columnValue(at: 0) == 1)
}
}
@Test func testRollbackPerformTransaction() throws {
struct DummyError: Error, Equatable {}
#expect(throws: DummyError(), performing: {
try self.service.perform(in: .deferred) { connection in
#expect(connection.isAutocommit == false)
let stmt = try connection.prepare(
sql: "INSERT INTO Item (name) VALUES (?)",
options: []
)
try stmt.bind("Book", at: 1)
try stmt.step()
throw DummyError()
}
})
try service.perform { connection in
let stmt = try connection.prepare(
sql: "SELECT COUNT(*) FROM Item",
options: []
)
try stmt.step()
#expect(connection.isAutocommit)
#expect(stmt.columnValue(at: 0) == 0)
}
}
@Test func testSuccessReconnectPerformTransaction() throws {
let connection = try Connection(
path: fileURL.path,
options: [.readwrite]
)
try connection.apply(currentKey, name: nil)
try connection.rekey(keyTwo, name: nil)
currentKey = keyTwo
try service.perform(in: .deferred) { connection in
#expect(connection.isAutocommit == false)
let stmt = try connection.prepare(
sql: "INSERT INTO Item (name) VALUES (?)",
options: []
)
try stmt.bind("Book", at: 1)
try stmt.step()
}
try service.perform { connection in
let stmt = try connection.prepare(
sql: "SELECT COUNT(*) FROM Item",
options: []
)
try stmt.step()
#expect(stmt.columnValue(at: 0) == 1)
}
}
@Test func testFailReconnectPerformTransaction() throws {
let connection = try Connection(
path: fileURL.path,
options: [.readwrite]
)
try connection.apply(currentKey, name: nil)
try connection.rekey(keyTwo, name: nil)
let error = SQLiteError(
code: SQLITE_NOTADB,
message: "file is not a database"
)
#expect(throws: error, performing: {
try self.service.perform(in: .deferred) { connection in
#expect(connection.isAutocommit == false)
let stmt = try connection.prepare(
sql: "INSERT INTO Item (name) VALUES (?)",
options: []
)
try stmt.bind("Book", at: 1)
try stmt.step()
}
})
let stmt = try connection.prepare(
sql: "SELECT COUNT(*) FROM Item",
options: []
)
try stmt.step()
#expect(connection.isAutocommit)
#expect(stmt.columnValue(at: 0) == 0)
}
}

View File

@@ -0,0 +1,102 @@
import Testing
import DataLiteCore
@testable import DataRaft
@Suite struct MigrationServiceTests {
private typealias MigrationService = DataRaft.MigrationService<VersionStorage>
private typealias MigrationError = DataRaft.MigrationError<MigrationService.Version>
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<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
let migration2 = Migration<Int32>(version: 2, byResource: "migration_2", extension: "sql", in: .module)!
let migration3 = Migration<Int32>(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<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
let migration2 = Migration<Int32>(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<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
let migration2 = Migration<Int32>(version: 2, byResource: "migration_2", extension: "sql", in: .module)!
let migration3 = Migration<Int32>(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<Int32>(version: 1, byResource: "migration_1", extension: "sql", in: .module)!
let migration2 = Migration<Int32>(version: 2, byResource: "migration_2", extension: "sql", in: .module)!
let migration4 = Migration<Int32>(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
}
}
}

View File

@@ -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<Version>()
let version = try storage.getVersion(connection)
#expect(version == Version(rawValue: 123))
}
@Test func getVersionWithError() {
connection.userVersion = 123
let storage = UserVersionStorage<NilVersion>()
do {
_ = try storage.getVersion(connection)
Issue.record("Expected failure for invalid stored version")
} catch UserVersionStorage<NilVersion>.Error.invalidStoredVersion(let version) {
#expect(version == UInt32(bitPattern: connection.userVersion))
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test func setVersion() throws {
let storage = UserVersionStorage<Version>()
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
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
-- Wrong sql statement
WRONG SQL STATEMENT;

View File

@@ -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)
}
}

View File

@@ -0,0 +1,68 @@
import Testing
import Foundation
@testable import DataRaft
@Suite struct MigrationTests {
@Test func initWithURL() {
let version = DummyVersion(rawValue: 1)
let url = URL(fileURLWithPath: "/tmp/migration.sql")
let migration = Migration(version: version, scriptURL: url)
#expect(migration.version == version)
#expect(migration.scriptURL == url)
}
@Test func initFromBundle_success() throws {
let version = DummyVersion(rawValue: 2)
let migration = Migration(
version: version,
byResource: "migration_1",
extension: "sql",
in: .module
)
#expect(migration != nil)
#expect(migration?.version == version)
#expect(migration?.scriptURL.lastPathComponent == "migration_1.sql")
}
@Test func initFromBundle_failure() {
let version = DummyVersion(rawValue: 3)
let migration = Migration(
version: version,
byResource: "NonexistentFile",
extension: "sql",
in: .main
)
#expect(migration == nil)
}
@Test func hashableEquatable() {
let version = DummyVersion(rawValue: 5)
let url = URL(fileURLWithPath: "/tmp/migration.sql")
let migration1 = Migration(version: version, scriptURL: url)
let migration2 = Migration(version: version, scriptURL: url)
#expect(migration1 == migration2)
#expect(migration1.hashValue == migration2.hashValue)
}
}
private extension MigrationTests {
struct DummyVersion: VersionRepresentable {
let rawValue: UInt32
init(rawValue: UInt32) {
self.rawValue = rawValue
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
}