Refactoring

This commit is contained in:
2025-11-07 20:38:09 +02:00
parent 5566f6f6ba
commit 5bbb722b20
26 changed files with 1097 additions and 770 deletions

View File

@@ -1,313 +1,255 @@
import Foundation
import DataLiteC
import DataLiteCore
import DataLiteC
/// Base service for working with a database.
/// A base database service handling transactions and event notifications.
///
/// `DatabaseService` provides a unified interface for performing operations using a database
/// connection, with built-in support for transactions, reconnection, and optional encryption
/// key management.
/// ## Overview
///
/// The service ensures thread-safe execution by serializing access to the connection through
/// an internal queue. This enables building modular and safe data access layers without
/// duplicating low-level logic.
/// `DatabaseService` provides a foundational layer for performing transactional database operations
/// within a thread-safe execution context. It automatically posts lifecycle notifications such as
/// commit, rollback, and content changes allowing observers to react to database updates in real
/// time. By default, it routes events through ``Foundation/NotificationCenter/database`` so that
/// clients can subscribe via a dedicated channel. This service is designed to be subclassed by
/// higher-level data managers that encapsulate domain logic while relying on consistent connection
/// and transaction handling.
///
/// The connection is established lazily on first use (e.g., within `perform`), not during
/// initialization. If a key provider is set, the key is applied as part of establishing or
/// restoring the connection.
///
/// Below is an example of creating a service for managing notes:
/// ## Usage
///
/// ```swift
/// final class NoteService: DatabaseService {
/// func insertNote(_ text: String) throws {
/// try perform { connection in
/// let stmt = try connection.prepare(sql: "INSERT INTO notes (text) VALUES (?)")
/// try perform(in: .deferred) { connection in
/// let sql = "INSERT INTO notes (text) VALUES (?)"
/// let stmt = try connection.prepare(sql: sql)
/// try stmt.bind(text, at: 0)
/// try stmt.step()
/// }
/// }
///
/// func fetchNotes() throws -> [String] {
/// try perform { connection in
/// let stmt = try connection.prepare(sql: "SELECT text FROM notes")
/// var result: [String] = []
/// while try stmt.step() {
/// if let text: String = stmt.columnValue(at: 0) {
/// result.append(text)
/// }
/// }
/// return result
/// }
/// }
/// }
///
/// let connection = try Connection(location: .inMemory, options: .readwrite)
/// let connection = try Connection(location: .inMemory, options: [])
/// let service = NoteService(connection: connection)
///
/// try service.insertNote("Hello, world!")
/// let notes = try service.fetchNotes()
/// print(notes) // ["Hello, world!"]
/// ```
///
/// ## Error Handling
///
/// All operations are executed on an internal serial queue, ensuring thread safety. If a
/// decryption error (`SQLITE_NOTADB`) is detected, the service may reopen the connection and
/// retry the transactional block exactly once. If the error occurs again, it is propagated
/// without further retries.
///
/// ## Encryption Key Management
///
/// If a ``keyProvider`` is set, the service uses it to obtain and apply an encryption key when
/// establishing or restoring the connection. If an error occurs while obtaining or applying the
/// key, the provider is notified through
/// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``.
///
/// ## Reconnection
///
/// Automatic reconnection is available only during transactional blocks executed with
/// ``perform(in:closure:)``. If a decryption error (`SQLITE_NOTADB`) occurs during a
/// transaction and the provider allows reconnection, the service obtains a new key, creates a
/// new connection, and retries the block once. If the second attempt fails or reconnection is
/// disallowed, the error is propagated without further retries.
///
/// ## Topics
///
/// ### Initializers
///
/// - ``ConnectionProvider``
/// - ``ConnectionConfig``
/// - ``init(provider:config:keyProvider:queue:)``
/// - ``init(connection:config:keyProvider:queue:)``
/// - ``ConnectionService/ConnectionProvider``
/// - ``ConnectionService/ConnectionConfig``
/// - ``init(provider:config:queue:center:)``
/// - ``init(provider:config:queue:)``
///
/// ### Key Management
/// ### Performing Operations
///
/// - ``DatabaseServiceKeyProvider``
/// - ``keyProvider``
///
/// ### Database Operations
///
/// - ``DatabaseServiceProtocol/Perform``
/// - ``ConnectionServiceProtocol/Perform``
/// - ``perform(_:)``
/// - ``perform(in:closure:)``
open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable {
// MARK: - Types
/// A closure that creates a new database connection.
///
/// `ConnectionProvider` is used for deferred connection creation.
/// It allows encapsulating initialization logic, configuration, and
/// error handling when opening the database.
///
/// - Returns: An initialized `Connection` instance.
/// - Throws: An error if the connection cannot be created or configured.
public typealias ConnectionProvider = () throws -> Connection
/// A closure used to configure a newly created connection.
///
/// Called after the connection is established (and after key application if present).
/// Can be used to set PRAGMA options or perform other initialization logic.
///
/// - Parameter connection: The newly created connection.
/// - Throws: Any error if configuration fails.
public typealias ConnectionConfig = (Connection) throws -> Void
///
/// ### Connection Delegate
///
/// - ``connection(_:didUpdate:)``
/// - ``connectionWillCommit(_:)``
/// - ``connectionDidRollback(_:)``
///
/// ### Notifications
///
/// - ``databaseDidChange``
/// - ``databaseWillCommit``
/// - ``databaseDidRollback``
/// - ``databaseDidPerform``
open class DatabaseService:
ConnectionService,
DatabaseServiceProtocol,
ConnectionDelegate,
@unchecked Sendable
{
// MARK: - Properties
private let provider: ConnectionProvider
private let config: ConnectionConfig?
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private let center: NotificationCenter
private var cachedConnection: Connection?
private var connection: Connection {
get throws {
guard let cachedConnection else {
let connection = try connect()
cachedConnection = connection
return connection
}
return cachedConnection
}
}
/// Encryption key provider.
/// Notification posted after the database content changes.
///
/// Used to obtain and apply a key when establishing or restoring a connection. The key is
/// requested on first access to the connection and on reconnection if needed.
public weak var keyProvider: DatabaseServiceKeyProvider?
/// Observers listen to this event to refresh cached data or update dependent components once
/// modifications are committed. The notifications `userInfo` may include
/// ``Foundation/Notification/UserInfoKey/action`` describing the SQLite action.
public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange")
/// Notification posted immediately before a transaction commits.
///
/// Observers can perform validation or prepare for an upcoming state change while the
/// transaction is still in progress.
public static let databaseWillCommit = Notification.Name("DatabaseService.databaseWillCommit")
/// Notification posted after a transaction rolls back.
///
/// Observers use this event to revert in-memory state or reset caches that rely on pending
/// changes.
public static let databaseDidRollback = Notification.Name("DatabaseService.databaseDidRollback")
/// Notification posted after any database operation completes, regardless of outcome.
///
/// The service emits this event after finishing a `perform(_:)` block so observers can
/// synchronize state even when the operation is read-only or aborted.
///
/// - Important: Confirm that the associated transaction was not rolled back before relying on
/// side effects.
public static let databaseDidPerform = Notification.Name("DatabaseService.databaseDidPerform")
// MARK: - Inits
/// Creates a new database service.
/// Creates a database service that posts lifecycle events to the provided notification center.
///
/// Configures the internal serial queue for thread-safe access to the database.
/// The connection is **not** created during initialization. It is established
/// lazily on first use (for example, inside `perform`).
///
/// The internal queue is always created with QoS `.utility`. If the `queue`
/// parameter is provided, it is used as the target queue for the internal one.
///
/// If a `keyProvider` is set, the encryption key will be applied when the
/// connection is established or restored.
/// The underlying connection handling matches ``ConnectionService``; the connection is created
/// lazily and all work executes on the managed serial queue.
///
/// - Parameters:
/// - provider: A closure that returns a new connection.
/// - config: An optional configuration closure called after the connection
/// is created (and after key application if present).
/// - keyProvider: An optional encryption key provider.
/// - queue: An optional target queue for the internal one.
/// - provider: A closure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal serial queue.
/// - center: A notification center for posting database events.
public init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
keyProvider: DatabaseServiceKeyProvider? = nil,
queue: DispatchQueue? = nil,
center: NotificationCenter
) {
self.center = center
super.init(provider: provider, config: config, queue: queue)
}
/// Creates a database service that posts lifecycle events to the shared database notification
/// center.
///
/// The connection is established lazily on first access and all work executes on the internal
/// queue defined in ``ConnectionService``.
///
/// - Parameters:
/// - provider: A closure that returns a new database connection.
/// - config: An optional configuration closure called after the connection is established and
/// the encryption key is applied.
/// - queue: An optional target queue for the internal serial queue.
public required init(
provider: @escaping ConnectionProvider,
config: ConnectionConfig? = nil,
queue: DispatchQueue? = nil
) {
self.provider = provider
self.config = config
self.keyProvider = keyProvider
self.queue = .init(for: Self.self, qos: .utility)
self.queue.setSpecific(key: queueKey, value: ())
if let queue = queue {
self.queue.setTarget(queue: queue)
self.center = .database
super.init(provider: provider, config: config, queue: queue)
}
// MARK: - Performing Operations
/// Executes a closure with a managed database connection and posts a completion notification.
///
/// The override mirrors ``ConnectionService/perform(_:)`` for queue-confined execution while
/// ensuring ``DatabaseService/databaseDidPerform`` is delivered after the closure completes.
///
/// - Parameter closure: The operation to execute using the open connection.
/// - Returns: The value returned by the closure.
/// - Throws: Errors thrown by the closure or underlying connection.
public override func perform<T>(_ closure: Perform<T>) throws -> T {
try super.perform { connection in
defer { center.post(name: Self.databaseDidPerform, object: self) }
return try closure(connection)
}
}
/// Creates a new database service.
/// Executes a closure inside a transaction when the connection operates in autocommit mode.
///
/// The connection is created lazily on first use. If a `keyProvider` is set,
/// the key will be applied when the connection is established.
/// The method begins the requested `TransactionType`, runs the closure, and commits the
/// transaction on success. Failures trigger a rollback. If the SQLite engine reports
/// `SQLITE_NOTADB` and the key provider allows reconnection, the service re-establishes the
/// connection and retries the closure once, mirroring the behavior described in
/// ``DatabaseServiceProtocol``.
///
/// - Parameters:
/// - provider: An expression that creates a new connection.
/// - config: An optional configuration closure called after the connection
/// is created (and after key application if present).
/// - keyProvider: An optional encryption key provider.
/// - queue: An optional target queue for the internal one.
public convenience init(
connection provider: @escaping @autoclosure ConnectionProvider,
config: ConnectionConfig? = nil,
keyProvider: DatabaseServiceKeyProvider? = nil,
queue: DispatchQueue? = nil
) {
self.init(
provider: provider,
config: config,
keyProvider: keyProvider,
queue: queue
)
}
// MARK: - Methods
/// Executes a closure with the current connection.
///
/// Ensures thread-safe access by running the closure on the internal serial queue.
/// The connection is created lazily if needed.
///
/// - Parameter closure: A closure that takes the active connection.
/// - transaction: The type of transaction to start (for example, `.deferred`).
/// - closure: The work to run while the transaction is active.
/// - Returns: The value returned by the closure.
/// - Throws: An error if the connection cannot be created or if the closure throws.
final public func perform<T>(_ closure: Perform<T>) throws -> T {
try withConnection(closure)
}
/// Executes a closure inside a transaction if the connection is in autocommit mode.
/// - Throws: Errors from the closure, transaction handling, or connection management.
///
/// If the connection is in autocommit mode, starts a new transaction of the specified
/// type, executes the closure, and commits changes on success. If the closure throws
/// an error, the transaction is rolled back.
///
/// If the closure throws `Connection.Error` with code `SQLITE_NOTADB` and reconnection
/// is allowed, the service attempts to create a new connection, reapply the key, and
/// retries the transaction block once. If the second attempt fails or reconnection
/// is disallowed, the error is propagated without further retries.
///
/// If a transaction is already active (connection not in autocommit mode), the closure
/// is executed directly without starting a new transaction.
///
/// - Parameters:
/// - transaction: The type of transaction to start.
/// - closure: A closure that takes the active connection and returns a result.
/// - Returns: The value returned by the closure.
/// - Throws: Errors from connection creation, key application, configuration,
/// transaction management, or from the closure itself.
/// - Important: The closure may be executed more than once. Ensure it is idempotent.
final public func perform<T>(
/// - 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 withConnection { connection in
if connection.isAutocommit {
do {
try connection.beginTransaction(transaction)
let result = try closure(connection)
try connection.commitTransaction()
return result
} catch {
try perform { connection in
guard connection.isAutocommit else {
return try closure(connection)
}
do {
try connection.beginTransaction(transaction)
let result = try closure(connection)
try connection.commitTransaction()
return result
} catch {
if !connection.isAutocommit {
try connection.rollbackTransaction()
guard let error = error as? Connection.Error,
error.code == SQLITE_NOTADB,
shouldReconnect
else { throw error }
try reconnect()
return try withConnection { connection in
do {
try connection.beginTransaction(transaction)
let result = try closure(connection)
try connection.commitTransaction()
return result
} catch {
}
guard
let error = error as? SQLiteError,
error.code == SQLITE_NOTADB,
setNeedsReconnect()
else {
throw error
}
return try perform { connection in
do {
try connection.beginTransaction(transaction)
let result = try closure(connection)
try connection.commitTransaction()
return result
} catch {
if !connection.isAutocommit {
try connection.rollbackTransaction()
throw error
}
throw error
}
}
} else {
return try closure(connection)
}
}
}
}
// MARK: - Private
private extension DatabaseService {
var shouldReconnect: Bool {
keyProvider?.databaseService(shouldReconnect: self) ?? false
// MARK: - ConnectionDelegate
/// Posts ``DatabaseService/databaseDidChange`` when the database content updates.
///
/// - Parameters:
/// - connection: The connection that performed the change.
/// - action: The SQLite action describing the modification.
public func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) {
let userInfo = [Notification.UserInfoKey.action: action]
center.post(name: Self.databaseDidChange, object: self, userInfo: userInfo)
}
func withConnection<T>(_ closure: Perform<T>) throws -> T {
switch DispatchQueue.getSpecific(key: queueKey) {
case .none: try queue.asyncAndWait { try closure(connection) }
case .some: try closure(connection)
}
/// Posts ``DatabaseService/databaseWillCommit`` before a transaction commits.
///
/// - Parameter connection: The connection preparing to commit.
public func connectionWillCommit(_ connection: any ConnectionProtocol) throws {
center.post(name: Self.databaseWillCommit, object: self)
}
func reconnect() throws {
cachedConnection = try connect()
/// Posts ``DatabaseService/databaseDidRollback`` after a transaction rollback.
///
/// - Parameter connection: The connection that rolled back.
public func connectionDidRollback(_ connection: any ConnectionProtocol) {
center.post(name: Self.databaseDidRollback, object: self)
}
func connect() throws -> Connection {
let connection = try provider()
try applyKey(to: connection)
try config?(connection)
// MARK: - Internal Methods
override func connect() throws -> any ConnectionProtocol {
let connection = try super.connect()
connection.add(delegate: self)
return connection
}
func applyKey(to connection: Connection) throws {
guard let keyProvider = keyProvider else { return }
do {
let key = try keyProvider.databaseService(keyFor: self)
let sql = "SELECT count(*) FROM sqlite_master"
try connection.apply(key)
try connection.execute(raw: sql)
} catch {
keyProvider.databaseService(self, didReceive: error)
throw error
}
}
}