diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67356a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +## General +.DS_Store +.swiftpm +.build/ + +## Various settings +Package.resolved +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xcuserdatad/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..b77abe6 --- /dev/null +++ b/.swift-format @@ -0,0 +1,15 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentBlankLines": true, + "indentation": { + "spaces": 4 + }, + "lineLength": 9999, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": false, + "rules": { + "FileScopedDeclarationPrivacy": true + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5ee8ce8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "DataLiteCore", + platforms: [ + .macOS(.v10_14), + .iOS(.v12) + ], + products: [ + .library( + name: "DataLiteCore", + targets: ["DataLiteCore"] + ) + ], + dependencies: [ + .package(url: "https://github.com/angd-dev/data-lite-c.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ], + targets: [ + .target( + name: "DataLiteCore", + dependencies: [ + .product(name: "DataLiteC", package: "data-lite-c"), + .product(name: "OrderedCollections", package: "swift-collections") + ], + cSettings: [ + .define("SQLITE_HAS_CODEC") + ] + ), + .testTarget( + name: "DataLiteCoreTests", + dependencies: ["DataLiteCore"], + cSettings: [ + .define("SQLITE_HAS_CODEC") + ] + ) + ] +) diff --git a/README.md b/README.md index 090f07e..318c91e 100644 --- a/README.md +++ b/README.md @@ -1 +1,63 @@ -# DataLiteCore Package +# DataLiteCore + +DataLiteCore is an intuitive library for working with SQLite in Swift applications. + +## Overview + +DataLiteCore provides an object-oriented API over the C interface, allowing developers to easily integrate SQLite functionality into their projects. The library offers powerful capabilities for database management and executing SQL queries while maintaining the simplicity and flexibility of the native Swift interface. + +## Key Features + +- **Connection Management** — a convenient interface for setting up connections to SQLite databases. +- **Preparation and Execution of SQL Statements** — support for parameterized queries for safe SQL execution. +- **Custom Function Integration** — the ability to add custom functions for use in SQL queries. +- **Native Error Handling** — easy error management using Swift's built-in error handling system. + +## Requirements + +- **Swift**: 6.0+ +- **Platforms**: macOS 10.14+, iOS 12.0+ + +## Installation + +To add DataLiteCore to your project, use Swift Package Manager (SPM). + +### 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-lite-core.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-lite-core.git", from: "1.0.0") + ], + targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "DataLiteCore", package: "data-lite-core") + ] + ) + ] +) +``` + +## Additional Resources + +For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=data-lite-core&version=1.0.0). + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/Sources/DataLiteCore/Classes/Connection+Key.swift b/Sources/DataLiteCore/Classes/Connection+Key.swift new file mode 100644 index 0000000..09fb4e9 --- /dev/null +++ b/Sources/DataLiteCore/Classes/Connection+Key.swift @@ -0,0 +1,41 @@ +import Foundation + +extension Connection { + /// An encryption key for opening an encrypted SQLite database. + /// + /// The key is applied after the connection is established to unlock the database contents. + /// Two formats are supported: + /// - a passphrase, which undergoes key derivation; + /// - a raw 256-bit key (32 bytes) passed without transformation. + public enum Key { + /// A human-readable passphrase used for key derivation. + /// + /// The passphrase is supplied as-is and processed by the underlying key derivation + /// mechanism configured in the database engine. + case passphrase(String) + + /// A raw 256-bit encryption key (32 bytes). + /// + /// The key is passed directly to the database without derivation. It must be securely + /// generated and stored. + case rawKey(Data) + + /// The string value passed to the database engine. + /// + /// For `.passphrase`, returns the passphrase exactly as provided. + /// For `.rawKey`, returns a hexadecimal literal in the format `X'...'`. + public var keyValue: String { + switch self { + case .passphrase(let string): + string + case .rawKey(let data): + data.sqliteLiteral + } + } + + /// The number of bytes in the string representation of the key. + public var length: Int32 { + Int32(keyValue.utf8.count) + } + } +} diff --git a/Sources/DataLiteCore/Classes/Connection+Location.swift b/Sources/DataLiteCore/Classes/Connection+Location.swift new file mode 100644 index 0000000..d66da75 --- /dev/null +++ b/Sources/DataLiteCore/Classes/Connection+Location.swift @@ -0,0 +1,44 @@ +import Foundation + +extension Connection { + /// A location specifying where the SQLite database is stored or created. + /// + /// Three locations are supported: + /// - ``file(path:)``: A database at a specific file path or URI (persistent). + /// - ``inMemory``: An in-memory database that exists only in RAM. + /// - ``temporary``: A temporary on-disk database deleted when the connection closes. + public enum Location: Sendable { + /// A database stored at a given file path or URI. + /// + /// Use this for persistent databases located on disk or referenced via SQLite URI. + /// The file is created if it does not exist (subject to open options). + /// + /// - Parameter path: Absolute/relative file path or URI. + /// - SeeAlso: [Uniform Resource Identifiers](https://sqlite.org/uri.html) + case file(path: String) + + /// A transient in-memory database. + /// + /// The database exists only in RAM and is discarded once the connection closes. + /// Suitable for testing, caching, or temporary data processing. + /// + /// - SeeAlso: [In-Memory Databases](https://sqlite.org/inmemorydb.html) + case inMemory + + /// A temporary on-disk database. + /// + /// Created on disk and removed automatically when the connection closes or the + /// process terminates. Useful for ephemeral data that should not persist. + /// + /// - SeeAlso: [Temporary Databases](https://sqlite.org/inmemorydb.html) + case temporary + + var path: String { + switch self { + case .file(let path): path + case .inMemory: ":memory:" + case .temporary: "" + } + } + } +} diff --git a/Sources/DataLiteCore/Classes/Connection+Options.swift b/Sources/DataLiteCore/Classes/Connection+Options.swift new file mode 100644 index 0000000..358a399 --- /dev/null +++ b/Sources/DataLiteCore/Classes/Connection+Options.swift @@ -0,0 +1,99 @@ +import Foundation +import DataLiteC + +extension Connection { + /// Options that control how the SQLite database connection is opened. + /// + /// Each option corresponds to a flag from the SQLite C API. Multiple options can be combined + /// using the `OptionSet` syntax. + /// + /// - SeeAlso: [Opening A New Database Connection](https://sqlite.org/c3ref/open.html) + public struct Options: OptionSet, Sendable { + // MARK: - Properties + + /// The raw integer value representing the option flags. + public var rawValue: Int32 + + // MARK: - Inits + + /// Creates a new set of options from a raw integer value. + /// + /// Combine multiple flags using bitwise OR (`|`). + /// + /// ```swift + /// let opts = Connection.Options( + /// rawValue: SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE + /// ) + /// ``` + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + // MARK: - Instances + + /// Opens the database in read-only mode. + /// + /// Fails if the database file does not exist. + public static let readonly = Self(rawValue: SQLITE_OPEN_READONLY) + + /// Opens the database for reading and writing. + /// + /// Fails if the file does not exist or is write-protected. + public static let readwrite = Self(rawValue: SQLITE_OPEN_READWRITE) + + /// Creates the database file if it does not exist. + /// + /// Commonly combined with `.readwrite`. + public static let create = Self(rawValue: SQLITE_OPEN_CREATE) + + /// Interprets the filename as a URI. + /// + /// Enables SQLite’s URI parameters and schemes. + /// - SeeAlso: [Uniform Resource Identifiers](https://sqlite.org/uri.html) + public static let uri = Self(rawValue: SQLITE_OPEN_URI) + + /// Opens an in-memory database. + /// + /// Data is stored in RAM and discarded when closed. + /// - SeeAlso: [In-Memory Databases](https://sqlite.org/inmemorydb.html) + public static let memory = Self(rawValue: SQLITE_OPEN_MEMORY) + + /// Disables mutexes for higher concurrency. + /// + /// Each thread must use a separate connection. + /// - SeeAlso: [Using SQLite In Multi-Threaded Applications]( + /// https://sqlite.org/threadsafe.html) + public static let nomutex = Self(rawValue: SQLITE_OPEN_NOMUTEX) + + /// Enables serialized access using full mutexes. + /// + /// Safe for concurrent access from multiple threads. + /// - SeeAlso: [Using SQLite In Multi-Threaded Applications]( + /// https://sqlite.org/threadsafe.html) + public static let fullmutex = Self(rawValue: SQLITE_OPEN_FULLMUTEX) + + /// Enables shared cache mode. + /// + /// Allows multiple connections to share cached data. + /// - SeeAlso: [SQLite Shared-Cache Mode](https://sqlite.org/sharedcache.html) + /// - Warning: Shared cache mode is discouraged by SQLite. + public static let sharedcache = Self(rawValue: SQLITE_OPEN_SHAREDCACHE) + + /// Disables shared cache mode. + /// + /// Each connection uses a private cache. + /// - SeeAlso: [SQLite Shared-Cache Mode](https://sqlite.org/sharedcache.html) + public static let privatecache = Self(rawValue: SQLITE_OPEN_PRIVATECACHE) + + /// Enables extended result codes. + /// + /// Provides more detailed SQLite error codes. + /// - SeeAlso: [Result and Error Codes](https://sqlite.org/rescode.html) + public static let exrescode = Self(rawValue: SQLITE_OPEN_EXRESCODE) + + /// Disallows following symbolic links. + /// + /// Improves security by preventing indirect file access. + public static let nofollow = Self(rawValue: SQLITE_OPEN_NOFOLLOW) + } +} diff --git a/Sources/DataLiteCore/Classes/Connection.swift b/Sources/DataLiteCore/Classes/Connection.swift new file mode 100644 index 0000000..d08389a --- /dev/null +++ b/Sources/DataLiteCore/Classes/Connection.swift @@ -0,0 +1,365 @@ +import Foundation +import DataLiteC + +/// A class representing a connection to an SQLite database. +/// +/// The `Connection` class manages the connection to an SQLite database. It provides an interface +/// for preparing SQL queries, managing transactions, and handling errors. This class serves as the +/// main object for interacting with the database. +public final class Connection { + // MARK: - Private Properties + + private let connection: OpaquePointer + + fileprivate var delegates = [DelegateBox]() { + didSet { + switch (oldValue.isEmpty, delegates.isEmpty) { + case (true, false): + let ctx = Unmanaged.passUnretained(self).toOpaque() + sqlite3_update_hook(connection, updateHookCallback(_:_:_:_:_:), ctx) + sqlite3_commit_hook(connection, commitHookCallback(_:), ctx) + sqlite3_rollback_hook(connection, rollbackHookCallback(_:), ctx) + case (false, true): + sqlite3_update_hook(connection, nil, nil) + sqlite3_commit_hook(connection, nil, nil) + sqlite3_rollback_hook(connection, nil, nil) + default: + break + } + } + } + + fileprivate var traceDelegates = [TraceDelegateBox]() { + didSet { + switch (oldValue.isEmpty, traceDelegates.isEmpty) { + case (true, false): + let ctx = Unmanaged.passUnretained(self).toOpaque() + sqlite3_trace_stmt(connection, traceCallback(_:_:_:_:), ctx) + case (false, true): + sqlite3_trace_stmt(connection, nil, nil) + default: + break + } + } + } + + // MARK: - Inits + + /// Initializes a new connection to an SQLite database. + /// + /// Opens a connection to the database at the specified `location` using the given `options`. + /// + /// ### Example + /// + /// ```swift + /// do { + /// let connection = try Connection( + /// location: .file(path: "/path/to/sqlite.db"), + /// options: .readwrite + /// ) + /// // Use the connection to execute SQL statements + /// } catch { + /// print("Failed to open database: \\(error)") + /// } + /// ``` + /// + /// - Parameters: + /// - location: The location of the database. Can represent a file path, an in-memory + /// database, or a temporary database. + /// - options: Connection options that define behavior such as read-only mode, creation + /// flags, and cache type. + /// + /// - Throws: ``SQLiteError`` if the connection cannot be opened or initialized due to + /// SQLite-related issues such as invalid path, missing permissions, or corruption. + public init(location: Location, options: Options) throws(SQLiteError) { + var connection: OpaquePointer! = nil + let status = sqlite3_open_v2(location.path, &connection, options.rawValue, nil) + + guard status == SQLITE_OK, let connection else { + let error = SQLiteError(connection) + sqlite3_close_v2(connection) + throw error + } + + self.connection = connection + } + + /// Initializes a new connection to an SQLite database using a file path. + /// + /// Opens a connection to the SQLite database located at the specified `path` using the provided + /// `options`. Internally, this method calls the designated initializer to perform the actual + /// setup and validation. + /// + /// ### Example + /// + /// ```swift + /// do { + /// let connection = try Connection( + /// path: "/path/to/sqlite.db", + /// options: .readwrite + /// ) + /// // Use the connection to execute SQL statements + /// } catch { + /// print("Failed to open database: \\(error)") + /// } + /// ``` + /// + /// - Parameters: + /// - path: The file system path to the SQLite database file. Can be absolute or relative. + /// - options: Options that control how the database is opened, such as access mode and + /// cache type. + /// + /// - Throws: ``SQLiteError`` if the connection cannot be opened due to SQLite-level errors, + /// invalid path, missing permissions, or corruption. + public convenience init(path: String, options: Options) throws(SQLiteError) { + try self.init(location: .file(path: path), options: options) + } + + deinit { + sqlite3_close_v2(connection) + } +} + +// MARK: - ConnectionProtocol + +extension Connection: ConnectionProtocol { + public var isAutocommit: Bool { + sqlite3_get_autocommit(connection) != 0 + } + + public var isReadonly: Bool { + sqlite3_db_readonly(connection, "main") == 1 + } + + public static func initialize() throws(SQLiteError) { + let status = sqlite3_initialize() + guard status == SQLITE_OK else { + throw SQLiteError(code: status, message: "") + } + } + + public static func shutdown() throws(SQLiteError) { + let status = sqlite3_shutdown() + guard status == SQLITE_OK else { + throw SQLiteError(code: status, message: "") + } + } + + public func apply(_ key: Key, name: String?) throws(SQLiteError) { + let status = if let name { + sqlite3_key_v2(connection, name, key.keyValue, key.length) + } else { + sqlite3_key(connection, key.keyValue, key.length) + } + guard status == SQLITE_OK else { + throw SQLiteError(code: status, message: "") + } + } + + public func rekey(_ key: Key, name: String?) throws(SQLiteError) { + let status = if let name { + sqlite3_rekey_v2(connection, name, key.keyValue, key.length) + } else { + sqlite3_rekey(connection, key.keyValue, key.length) + } + guard status == SQLITE_OK else { + throw SQLiteError(code: status, message: "") + } + } + + public func add(delegate: any ConnectionDelegate) { + if !delegates.contains(where: { $0.delegate === delegate }) { + delegates.append(.init(delegate: delegate)) + delegates.removeAll { $0.delegate == nil } + } + } + + public func remove(delegate: any ConnectionDelegate) { + delegates.removeAll { + $0.delegate === delegate || $0.delegate == nil + } + } + + public func add(trace delegate: any ConnectionTraceDelegate) { + if !traceDelegates.contains(where: { $0.delegate === delegate }) { + traceDelegates.append(.init(delegate: delegate)) + traceDelegates.removeAll { $0.delegate == nil } + } + } + + public func remove(trace delegate: any ConnectionTraceDelegate) { + traceDelegates.removeAll { + $0.delegate === delegate || $0.delegate == nil + } + } + + public func add(function: Function.Type) throws(SQLiteError) { + try function.install(db: connection) + } + + public func remove(function: Function.Type) throws(SQLiteError) { + try function.uninstall(db: connection) + } + + public func prepare( + sql query: String, options: Statement.Options + ) throws(SQLiteError) -> any StatementProtocol { + try Statement(db: connection, sql: query, options: options) + } + + public func execute(sql script: String) throws(SQLiteError) { + let status = sqlite3_exec(connection, script, nil, nil, nil) + guard status == SQLITE_OK else { throw SQLiteError(connection) } + } +} + +// MARK: - DelegateBox + +fileprivate extension Connection { + class DelegateBox { + weak var delegate: ConnectionDelegate? + + init(delegate: ConnectionDelegate) { + self.delegate = delegate + } + } +} + +// MARK: - TraceDelegateBox + +fileprivate extension Connection { + class TraceDelegateBox { + weak var delegate: ConnectionTraceDelegate? + + init(delegate: ConnectionTraceDelegate) { + self.delegate = delegate + } + } +} + +// MARK: - Functions + +private typealias TraceCallback = @convention(c) ( + UInt32, + UnsafeMutableRawPointer?, + UnsafeMutableRawPointer?, + UnsafeMutableRawPointer? +) -> Int32 + +@discardableResult +private func sqlite3_trace_stmt( + _ db: OpaquePointer!, + _ callback: TraceCallback!, + _ ctx: UnsafeMutableRawPointer! +) -> Int32 { + sqlite3_trace_v2(db, SQLITE_TRACE_STMT, callback, ctx) +} + +@discardableResult +private func sqlite3_trace_v2( + _ db: OpaquePointer!, + _ mask: Int32, + _ callback: TraceCallback!, + _ ctx: UnsafeMutableRawPointer! +) -> Int32 { + sqlite3_trace_v2(db, UInt32(mask), callback, ctx) +} + +private func traceCallback( + _ flag: UInt32, + _ ctx: UnsafeMutableRawPointer?, + _ p: UnsafeMutableRawPointer?, + _ x: UnsafeMutableRawPointer? +) -> Int32 { + guard let ctx, + let stmt = OpaquePointer(p) + else { return SQLITE_OK } + + let connection = Unmanaged + .fromOpaque(ctx) + .takeUnretainedValue() + + let xSql = x?.assumingMemoryBound(to: CChar.self) + let pSql = sqlite3_expanded_sql(stmt) + + defer { sqlite3_free(pSql) } + + guard let xSql, let pSql else { + return SQLITE_OK + } + + let xSqlString = String(cString: xSql) + let pSqlString = String(cString: pSql) + let trace = (xSqlString, pSqlString) + + for box in connection.traceDelegates { + box.delegate?.connection(connection, trace: trace) + } + + return SQLITE_OK +} + +private func updateHookCallback( + _ ctx: UnsafeMutableRawPointer?, + _ action: Int32, + _ dName: UnsafePointer?, + _ tName: UnsafePointer?, + _ rowID: sqlite3_int64 +) { + guard let ctx else { return } + + let connection = Unmanaged + .fromOpaque(ctx) + .takeUnretainedValue() + + guard let dName = dName, let tName = tName else { return } + + let dbName = String(cString: dName) + let tableName = String(cString: tName) + + let updateAction: SQLiteAction? = switch action { + case SQLITE_INSERT: .insert(db: dbName, table: tableName, rowID: rowID) + case SQLITE_UPDATE: .update(db: dbName, table: tableName, rowID: rowID) + case SQLITE_DELETE: .delete(db: dbName, table: tableName, rowID: rowID) + default: nil + } + + guard let updateAction else { return } + + for box in connection.delegates { + box.delegate?.connection(connection, didUpdate: updateAction) + } +} + +private func commitHookCallback( + _ ctx: UnsafeMutableRawPointer? +) -> Int32 { + guard let ctx = ctx else { return SQLITE_OK } + + let connection = Unmanaged + .fromOpaque(ctx) + .takeUnretainedValue() + + do { + for box in connection.delegates { + try box.delegate?.connectionWillCommit(connection) + } + return SQLITE_OK + } catch { + return SQLITE_ERROR + } +} + +private func rollbackHookCallback( + _ ctx: UnsafeMutableRawPointer? +) { + guard let ctx = ctx else { return } + + let connection = Unmanaged + .fromOpaque(ctx) + .takeUnretainedValue() + + for box in connection.delegates { + box.delegate?.connectionDidRollback(connection) + } +} diff --git a/Sources/DataLiteCore/Classes/Function+Aggregate.swift b/Sources/DataLiteCore/Classes/Function+Aggregate.swift new file mode 100644 index 0000000..6ad460e --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function+Aggregate.swift @@ -0,0 +1,230 @@ +import Foundation +import DataLiteC + +extension Function { + /// Base class for defining custom SQLite aggregate functions. + /// + /// The `Aggregate` class provides a foundation for creating aggregate + /// functions in SQLite. Aggregate functions operate on multiple rows of + /// input and return a single result value. + /// + /// To define a custom aggregate function, subclass `Function.Aggregate` and + /// override the following: + /// + /// - ``name`` – The SQL name of the function. + /// - ``argc`` – The number of arguments accepted by the function. + /// - ``options`` – Function options, such as `.deterministic` or `.innocuous`. + /// - ``step(args:)`` – Called for each row's argument values. + /// - ``finalize()`` – Called once to compute and return the final result. + /// + /// ### Example + /// + /// ```swift + /// final class SumAggregate: Function.Aggregate { + /// enum Error: Swift.Error { + /// case argumentsWrong + /// } + /// + /// override class var argc: Int32 { 1 } + /// override class var name: String { "sum_aggregate" } + /// override class var options: Function.Options { + /// [.deterministic, .innocuous] + /// } + /// + /// private var sum: Int = 0 + /// + /// override func step(args: ArgumentsProtocol) throws { + /// guard let value = args[0] as Int? else { + /// throw Error.argumentsWrong + /// } + /// sum += value + /// } + /// + /// override func finalize() throws -> SQLiteRepresentable? { + /// return sum + /// } + /// } + /// ``` + /// + /// ### Registration + /// + /// ```swift + /// let connection = try Connection( + /// path: dbFileURL.path, + /// options: [.create, .readwrite] + /// ) + /// try connection.add(function: SumAggregate.self) + /// ``` + /// + /// ### SQL Example + /// + /// ```sql + /// SELECT sum_aggregate(value) FROM my_table + /// ``` + /// + /// ## Topics + /// + /// ### Initialization + /// + /// - ``init()`` + /// + /// ### Instance Methods + /// + /// - ``step(args:)`` + /// - ``finalize()`` + open class Aggregate: Function { + // MARK: - Properties + + fileprivate var hasErrored = false + + // MARK: - Inits + + /// Initializes a new aggregate function instance. + /// + /// Subclasses may override this initializer to perform custom setup. + /// The base implementation performs no additional actions. + /// + /// - Important: Always call `super.init()` when overriding. + required public override init() {} + + // MARK: - Methods + + override class func install(db connection: OpaquePointer) throws(SQLiteError) { + let context = Context(function: self) + let ctx = Unmanaged.passRetained(context).toOpaque() + let status = sqlite3_create_function_v2( + connection, name, argc, opts, ctx, + nil, xStep(_:_:_:), xFinal(_:), xDestroy(_:) + ) + if status != SQLITE_OK { + throw SQLiteError(connection) + } + } + + /// Processes one step of the aggregate computation. + /// + /// This method is called once for each row of input data. Subclasses must override it to + /// accumulate intermediate results. + /// + /// - Parameter args: The set of arguments passed to the function. + /// - Throws: An error if the input arguments are invalid or the computation fails. + /// + /// - Note: The default implementation triggers a runtime error. + open func step(args: any ArgumentsProtocol) throws { + fatalError("Subclasses must override `step(args:)`.") + } + + /// Finalizes the aggregate computation and returns the result. + /// + /// SQLite calls this method once after all input rows have been processed. + /// Subclasses must override it to produce the final result of the aggregate. + /// + /// - Returns: The final computed value, or `nil` if the function produces no result. + /// - Throws: An error if the computation cannot be finalized. + /// - Note: The default implementation triggers a runtime error. + open func finalize() throws -> SQLiteRepresentable? { + fatalError("Subclasses must override `finalize()`.") + } + } +} + +extension Function.Aggregate { + fileprivate final class Context { + // MARK: - Properties + + private let function: Aggregate.Type + + // MARK: - Inits + + init(function: Aggregate.Type) { + self.function = function + } + + // MARK: - Methods + + func function( + for ctx: OpaquePointer?, isFinal: Bool = false + ) -> Unmanaged? { + typealias U = Unmanaged + + let bytes = isFinal ? 0 : MemoryLayout.stride + let raw = sqlite3_aggregate_context(ctx, Int32(bytes)) + guard let raw else { return nil } + + let pointer = raw.assumingMemoryBound(to: U?.self) + + if let pointer = pointer.pointee { + return pointer + } else { + let function = self.function.init() + pointer.pointee = Unmanaged.passRetained(function) + return pointer.pointee + } + } + } +} + +// MARK: - Functions + +private func xStep( + _ ctx: OpaquePointer?, + _ argc: Int32, + _ argv: UnsafeMutablePointer? +) { + let function = Unmanaged + .fromOpaque(sqlite3_user_data(ctx)) + .takeUnretainedValue() + .function(for: ctx)? + .takeUnretainedValue() + + guard let function else { + sqlite3_result_error_nomem(ctx) + return + } + + assert(!function.hasErrored) + + do { + let args = Function.Arguments(argc: argc, argv: argv) + try function.step(args: args) + } catch { + let name = type(of: function).name + let description = error.localizedDescription + let message = "Error executing function '\(name)': \(description)" + function.hasErrored = true + sqlite3_result_error(ctx, message, -1) + sqlite3_result_error_code(ctx, SQLITE_ERROR) + } +} + +private func xFinal(_ ctx: OpaquePointer?) { + let pointer = Unmanaged + .fromOpaque(sqlite3_user_data(ctx)) + .takeUnretainedValue() + .function(for: ctx, isFinal: true) + + defer { pointer?.release() } + + guard let function = pointer?.takeUnretainedValue() else { + sqlite3_result_null(ctx) + return + } + + guard !function.hasErrored else { return } + + do { + let result = try function.finalize() + sqlite3_result_value(ctx, result?.sqliteValue) + } catch { + let name = type(of: function).name + let description = error.localizedDescription + let message = "Error executing function '\(name)': \(description)" + sqlite3_result_error(ctx, message, -1) + sqlite3_result_error_code(ctx, SQLITE_ERROR) + } +} + +private func xDestroy(_ ctx: UnsafeMutableRawPointer?) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).release() +} diff --git a/Sources/DataLiteCore/Classes/Function+Arguments.swift b/Sources/DataLiteCore/Classes/Function+Arguments.swift new file mode 100644 index 0000000..910c37f --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function+Arguments.swift @@ -0,0 +1,92 @@ +import Foundation +import DataLiteC + +extension Function { + /// A collection representing the arguments passed to an SQLite function. + /// + /// The `Arguments` structure provides a type-safe interface for accessing the arguments + /// received by a user-defined SQLite function. Each element of the collection is represented + /// by a ``SQLiteValue`` instance that can store integers, floating-point numbers, text, blobs, + /// or nulls. + public struct Arguments: ArgumentsProtocol { + // MARK: - Properties + + private let argc: Int32 + private let argv: UnsafeMutablePointer? + + /// The number of arguments passed to the SQLite function. + public var count: Int { + Int(argc) + } + + /// A Boolean value indicating whether there are no arguments. + public var isEmpty: Bool { + count == 0 + } + + /// The index of the first argument. + public var startIndex: Index { + 0 + } + + /// The index immediately after the last valid argument. + public var endIndex: Index { + count + } + + // MARK: - Inits + + init(argc: Int32, argv: UnsafeMutablePointer?) { + self.argc = argc + self.argv = argv + } + + // MARK: - Subscripts + + /// Returns the SQLite value at the specified index. + /// + /// Retrieves the raw value from the SQLite function arguments and returns it as an + /// instance of ``SQLiteValue``. + /// + /// - Parameter index: The index of the argument to retrieve. + /// - Returns: The SQLite value at the specified index. + /// - Complexity: O(1) + public subscript(index: Index) -> Element { + guard index < count else { + fatalError("Index \(index) out of bounds") + } + let arg = argv.unsafelyUnwrapped[index] + switch sqlite3_value_type(arg) { + case SQLITE_INTEGER: return .int(sqlite3_value_int64(arg)) + case SQLITE_FLOAT: return .real(sqlite3_value_double(arg)) + case SQLITE_TEXT: return .text(sqlite3_value_text(arg)) + case SQLITE_BLOB: return .blob(sqlite3_value_blob(arg)) + default: return .null + } + } + + // MARK: - Methods + + /// Returns the index that follows the specified index. + /// + /// - Parameter i: The current index. + /// - Returns: The index immediately after the specified one. + /// - Complexity: O(1) + public func index(after i: Index) -> Index { + i + 1 + } + } +} + +// MARK: - Functions + +private func sqlite3_value_text(_ value: OpaquePointer!) -> String { + String(cString: DataLiteC.sqlite3_value_text(value)) +} + +private func sqlite3_value_blob(_ value: OpaquePointer!) -> Data { + Data( + bytes: sqlite3_value_blob(value), + count: Int(sqlite3_value_bytes(value)) + ) +} diff --git a/Sources/DataLiteCore/Classes/Function+Options.swift b/Sources/DataLiteCore/Classes/Function+Options.swift new file mode 100644 index 0000000..5ca96ec --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function+Options.swift @@ -0,0 +1,55 @@ +import Foundation +import DataLiteC + +extension Function { + /// An option set representing the configuration flags for an SQLite function. + /// + /// The `Options` structure defines a set of flags that control the behavior of a user-defined + /// SQLite function. Multiple options can be combined using bitwise OR operations. + /// + /// - SeeAlso: [Function Flags](https://sqlite.org/c3ref/c_deterministic.html) + public struct Options: OptionSet, Hashable, Sendable { + // MARK: - Properties + + /// The raw integer value representing the combined SQLite function options. + public var rawValue: Int32 + + // MARK: - Options + + /// Marks the function as deterministic. + /// + /// A deterministic function always produces the same output for the same input parameters. + /// For example, mathematical functions like `sqrt()` or `abs()` are deterministic. + public static let deterministic = Self(rawValue: SQLITE_DETERMINISTIC) + + /// Restricts the function to be invoked only from top-level SQL. + /// + /// A function with the `directonly` flag cannot be used in views, triggers, or schema + /// definitions such as `CHECK` constraints, `DEFAULT` clauses, expression indexes, partial + /// indexes, or generated columns. + /// + /// This option is recommended for functions that may have side effects or expose sensitive + /// information. It helps prevent attacks involving maliciously crafted database schemas + /// that attempt to invoke such functions implicitly. + public static let directonly = Self(rawValue: SQLITE_DIRECTONLY) + + /// Marks the function as innocuous. + /// + /// The `innocuous` flag indicates that the function is safe even if misused. Such a + /// function should have no side effects and depend only on its input parameters. For + /// instance, `abs()` is innocuous, while `load_extension()` is not due to its side effects. + /// + /// This option is similar to ``deterministic`` but not identical. For example, `random()` + /// is innocuous but not deterministic. + public static let innocuous = Self(rawValue: SQLITE_INNOCUOUS) + + // MARK: - Inits + + /// Creates a new set of SQLite function options from the specified raw value. + /// + /// - Parameter rawValue: The raw value representing the SQLite function options. + public init(rawValue: Int32) { + self.rawValue = rawValue + } + } +} diff --git a/Sources/DataLiteCore/Classes/Function+Regexp.swift b/Sources/DataLiteCore/Classes/Function+Regexp.swift new file mode 100644 index 0000000..0db0100 --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function+Regexp.swift @@ -0,0 +1,80 @@ +import Foundation + +extension Function { + /// A scalar SQL function that performs regular expression matching. + /// + /// This function checks whether a given string matches a specified regular expression pattern + /// and returns `true` if it matches, or `false` otherwise. + /// + /// ```swift + /// let connection = try Connection( + /// location: .inMemory, + /// options: [.create, .readwrite] + /// ) + /// try connection.add(function: Function.Regexp.self) + /// + /// try connection.execute(sql: """ + /// SELECT * FROM users WHERE name REGEXP 'John.*'; + /// """) + /// ``` + @available(iOS 16.0, *) + @available(macOS 13.0, *) + public final class Regexp: Scalar { + /// Errors that can occur during the evaluation of the `REGEXP` function. + public enum Error: Swift.Error { + /// Thrown when the arguments provided to the function are invalid. + case invalidArguments + + /// Thrown when an error occurs while processing the regular expression. + /// - Parameter error: The underlying error from the regex operation. + case regexError(Swift.Error) + } + + // MARK: - Properties + + /// The number of arguments required by the function. + /// + /// The `REGEXP` function expects exactly two arguments: + /// 1. A string containing the regular expression pattern. + /// 2. A string value to be evaluated against the pattern. + public override class var argc: Int32 { 2 } + + /// The name of the SQL function. + /// + /// The SQL function can be invoked in queries using the name `REGEXP`. + public override class var name: String { "REGEXP" } + + /// Options that define the behavior of the SQL function. + /// + /// - `deterministic`: Ensures the same result is returned for identical inputs. + /// - `innocuous`: Indicates the function does not have any side effects. + public override class var options: Options { + [.deterministic, .innocuous] + } + + // MARK: - Methods + + /// Invokes the `REGEXP` function to evaluate whether a string matches a regular expression. + /// + /// - Parameters: + /// - args: The arguments provided to the SQL function. + /// - `args[0]`: The regular expression pattern as a `String`. + /// - `args[1]`: The value to match against the pattern as a `String`. + /// - Returns: `true` if the value matches the pattern, `false` otherwise. + /// - Throws: ``Error/invalidArguments`` if the arguments are invalid or missing. + /// - Throws: ``Error/regexError(_:)`` if an error occurs during regex evaluation. + public override class func invoke( + args: any ArgumentsProtocol + ) throws -> SQLiteRepresentable? { + guard let regex = args[0] as String?, + let value = args[1] as String? + else { throw Error.invalidArguments } + + do { + return try Regex(regex).wholeMatch(in: value) != nil + } catch { + throw Error.regexError(error) + } + } + } +} diff --git a/Sources/DataLiteCore/Classes/Function+Scalar.swift b/Sources/DataLiteCore/Classes/Function+Scalar.swift new file mode 100644 index 0000000..0cd33da --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function+Scalar.swift @@ -0,0 +1,141 @@ +import Foundation +import DataLiteC + +extension Function { + /// A base class for defining custom scalar SQLite functions. + /// + /// The `Scalar` class provides a foundation for defining scalar functions in SQLite. Scalar + /// functions take one or more input arguments and return a single value for each function call. + /// + /// To define a custom scalar function, subclass `Function.Scalar` and override the following + /// members: + /// + /// - ``name`` – The SQL name of the function. + /// - ``argc`` – The number of arguments the function accepts. + /// - ``options`` – Function options, such as `.deterministic` or `.innocuous`. + /// - ``invoke(args:)`` – The method implementing the function’s logic. + /// + /// ### Example + /// + /// ```swift + /// @available(macOS 13.0, *) + /// final class Regexp: Function.Scalar { + /// enum Error: Swift.Error { + /// case argumentsWrong + /// case regexError(Swift.Error) + /// } + /// + /// override class var argc: Int32 { 2 } + /// override class var name: String { "REGEXP" } + /// override class var options: Function.Options { + /// [.deterministic, .innocuous] + /// } + /// + /// override class func invoke( + /// args: ArgumentsProtocol + /// ) throws -> SQLiteRepresentable? { + /// guard let regex = args[0] as String?, + /// let value = args[1] as String? + /// else { throw Error.argumentsWrong } + /// do { + /// return try Regex(regex).wholeMatch(in: value) != nil + /// } catch { + /// throw Error.regexError(error) + /// } + /// } + /// } + /// ``` + /// + /// ### Usage + /// + /// To use a custom function, register it with an SQLite connection: + /// + /// ```swift + /// let connection = try Connection( + /// path: dbFileURL.path, + /// options: [.create, .readwrite] + /// ) + /// try connection.add(function: Regexp.self) + /// ``` + /// + /// ### SQL Example + /// + /// After registration, the function becomes available in SQL expressions: + /// + /// ```sql + /// SELECT * FROM users WHERE REGEXP('John.*', name); + /// ``` + open class Scalar: Function { + // MARK: - Methods + + override class func install(db connection: OpaquePointer) throws(SQLiteError) { + let context = Context(function: self) + let ctx = Unmanaged.passRetained(context).toOpaque() + let status = sqlite3_create_function_v2( + connection, name, argc, opts, ctx, + xFunc(_:_:_:), nil, nil, xDestroy(_:) + ) + if status != SQLITE_OK { + throw SQLiteError(connection) + } + } + + /// Implements the logic of the custom scalar function. + /// + /// Subclasses must override this method to process the provided arguments and return a + /// result value for the scalar function call. + /// + /// - Parameter args: The set of arguments passed to the function. + /// - Returns: The result of the function call, represented as ``SQLiteRepresentable``. + /// - Throws: An error if the arguments are invalid or the computation fails. + /// + /// - Note: The default implementation triggers a runtime error. + open class func invoke(args: any ArgumentsProtocol) throws -> SQLiteRepresentable? { + fatalError("Subclasses must override this method to implement function logic.") + } + } +} + +extension Function.Scalar { + fileprivate final class Context { + // MARK: - Properties + + let function: Scalar.Type + + // MARK: - Inits + + init(function: Scalar.Type) { + self.function = function + } + } +} + +// MARK: - Functions + +private func xFunc( + _ ctx: OpaquePointer?, + _ argc: Int32, + _ argv: UnsafeMutablePointer? +) { + let function = Unmanaged + .fromOpaque(sqlite3_user_data(ctx)) + .takeUnretainedValue() + .function + + do { + let args = Function.Arguments(argc: argc, argv: argv) + let result = try function.invoke(args: args) + sqlite3_result_value(ctx, result?.sqliteValue) + } catch { + let name = function.name + let description = error.localizedDescription + let message = "Error executing function '\(name)': \(description)" + sqlite3_result_error(ctx, message, -1) + sqlite3_result_error_code(ctx, SQLITE_ERROR) + } +} + +private func xDestroy(_ ctx: UnsafeMutableRawPointer?) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).release() +} diff --git a/Sources/DataLiteCore/Classes/Function.swift b/Sources/DataLiteCore/Classes/Function.swift new file mode 100644 index 0000000..88e5e24 --- /dev/null +++ b/Sources/DataLiteCore/Classes/Function.swift @@ -0,0 +1,108 @@ +import Foundation +import DataLiteC + +/// A base class representing a user-defined SQLite function. +/// +/// The `Function` class defines the common interface and structure for implementing custom SQLite +/// functions. Subclasses are responsible for specifying the function name, argument count, and +/// behavior. This class should not be used directly — instead, use one of its specialized +/// subclasses, such as ``Scalar`` or ``Aggregate``. +/// +/// To define a new SQLite function, subclass either ``Scalar`` or ``Aggregate`` depending on +/// whether the function computes a value from a single row or aggregates results across multiple +/// rows. Override the required properties and implement the necessary logic to define the +/// function’s behavior. +/// +/// ## Topics +/// +/// ### Base Function Classes +/// +/// - ``Scalar`` +/// - ``Aggregate`` +/// +/// ### Custom Function Classes +/// +/// - ``Regexp`` +/// +/// ### Configuration +/// +/// - ``argc`` +/// - ``name`` +/// - ``options`` +/// - ``Options`` +open class Function { + // MARK: - Properties + + /// The number of arguments that the function accepts. + /// + /// Subclasses must override this property to specify the expected number of arguments. The + /// value should be a positive integer, or zero if the function does not accept any arguments. + open class var argc: Int32 { + fatalError("Subclasses must override this property to specify the number of arguments.") + } + + /// The name of the function. + /// + /// Subclasses must override this property to provide the name by which the SQLite engine + /// identifies the function. The name must comply with SQLite function naming rules. + open class var name: String { + fatalError("Subclasses must override this property to provide the function name.") + } + + /// The configuration options for the function. + /// + /// Subclasses must override this property to specify the function’s behavioral flags, such as + /// whether it is deterministic, direct-only, or innocuous. + open class var options: Options { + fatalError("Subclasses must override this property to specify function options.") + } + + class var encoding: Function.Options { + Function.Options(rawValue: SQLITE_UTF8) + } + + class var opts: Int32 { + var options = options + options.insert(encoding) + return options.rawValue + } + + // MARK: - Methods + + class func install(db connection: OpaquePointer) throws(SQLiteError) { + fatalError("Subclasses must override this method to implement function installation.") + } + + class func uninstall(db connection: OpaquePointer) throws(SQLiteError) { + let status = sqlite3_create_function_v2( + connection, + name, argc, opts, + nil, nil, nil, nil, nil + ) + if status != SQLITE_OK { + throw SQLiteError(connection) + } + } +} + +// MARK: - Functions + +func sqlite3_result_text(_ ctx: OpaquePointer!, _ string: String) { + sqlite3_result_text(ctx, string, -1, SQLITE_TRANSIENT) +} + +func sqlite3_result_blob(_ ctx: OpaquePointer!, _ data: Data) { + data.withUnsafeBytes { + sqlite3_result_blob(ctx, $0.baseAddress, Int32($0.count), SQLITE_TRANSIENT) + } +} + +func sqlite3_result_value(_ ctx: OpaquePointer!, _ value: SQLiteValue?) { + switch value ?? .null { + case .int(let value): sqlite3_result_int64(ctx, value) + case .real(let value): sqlite3_result_double(ctx, value) + case .text(let value): sqlite3_result_text(ctx, value) + case .blob(let value): sqlite3_result_blob(ctx, value) + case .null: sqlite3_result_null(ctx) + } +} diff --git a/Sources/DataLiteCore/Classes/Statement+Options.swift b/Sources/DataLiteCore/Classes/Statement+Options.swift new file mode 100644 index 0000000..e74c16b --- /dev/null +++ b/Sources/DataLiteCore/Classes/Statement+Options.swift @@ -0,0 +1,72 @@ +import Foundation +import DataLiteC + +extension Statement { + /// A set of options that control how an SQLite statement is prepared. + /// + /// `Options` conforms to the `OptionSet` protocol, allowing multiple flags to be combined. + /// Each option corresponds to a specific SQLite preparation flag. + /// + /// - SeeAlso: [Prepare Flags](https://sqlite.org/c3ref/c_prepare_normalize.html) + /// + /// ## Topics + /// + /// ### Initializers + /// + /// - ``init(rawValue:)-(UInt32)`` + /// - ``init(rawValue:)-(Int32)`` + /// + /// ### Instance Properties + /// + /// - ``rawValue`` + /// + /// ### Type Properties + /// + /// - ``persistent`` + /// - ``noVtab`` + public struct Options: OptionSet, Sendable { + // MARK: - Properties + + /// The raw bitmask value that represents the combined options. + /// + /// Each bit in the mask corresponds to a specific SQLite preparation flag. ou can use this + /// value for low-level bitwise operations or to construct an `Options` instance directly. + public var rawValue: UInt32 + + /// Indicates that the prepared statement is persistent and reusable. + /// + /// This flag hints to SQLite that the prepared statement will be kept and reused multiple + /// times. Without this hint, SQLite assumes the statement will be used only a few times and + /// then destroyed. + /// + /// Using `.persistent` can help avoid excessive lookaside memory usage and improve + /// performance for frequently executed statements. + public static let persistent = Self(rawValue: SQLITE_PREPARE_PERSISTENT) + + /// Disables the use of virtual tables in the prepared statement. + /// + /// When this flag is set, any attempt to reference a virtual table during statement + /// preparation results in an error. Use this option when virtual tables are restricted or + /// undesirable for security or policy reasons. + public static let noVtab = Self(rawValue: SQLITE_PREPARE_NO_VTAB) + + // MARK: - Inits + + /// Creates a new set of options from a raw `UInt32` bitmask value. + /// + /// - Parameter rawValue: The bitmask value that represents the combined options. + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// Creates a new set of options from a raw `Int32` bitmask value. + /// + /// This initializer allows working directly with SQLite C constants that use + /// 32-bit integers. + /// + /// - Parameter rawValue: The bitmask value that represents the combined options. + public init(rawValue: Int32) { + self.rawValue = UInt32(rawValue) + } + } +} diff --git a/Sources/DataLiteCore/Classes/Statement.swift b/Sources/DataLiteCore/Classes/Statement.swift new file mode 100644 index 0000000..5aa7dfe --- /dev/null +++ b/Sources/DataLiteCore/Classes/Statement.swift @@ -0,0 +1,177 @@ +import Foundation +import DataLiteC + +/// A prepared SQLite statement used to execute SQL commands. +/// +/// `Statement` encapsulates the lifecycle of a compiled SQL statement, including parameter binding, +/// execution, and result retrieval. The statement is finalized automatically when the instance is +/// deallocated. +/// +/// This class serves as a thin, type-safe wrapper over the SQLite C API, providing a Swift +/// interface for managing prepared statements. +/// +/// ## Topics +/// +/// ### Statement Options +/// +/// - ``Options`` +public final class Statement { + // MARK: - Private Properties + + private let statement: OpaquePointer + private let connection: OpaquePointer + + // MARK: - Inits + + init( + db connection: OpaquePointer, + sql query: String, + options: Options + ) throws(SQLiteError) { + var statement: OpaquePointer! = nil + let status = sqlite3_prepare_v3( + connection, query, -1, + options.rawValue, &statement, nil + ) + + if status == SQLITE_OK, let statement { + self.statement = statement + self.connection = connection + } else { + sqlite3_finalize(statement) + throw SQLiteError(connection) + } + } + + deinit { + sqlite3_finalize(statement) + } +} + +// MARK: - StatementProtocol + +extension Statement: StatementProtocol { + public var sql: String? { + let cSQL = sqlite3_sql(statement) + guard let cSQL else { return nil } + return String(cString: cSQL) + } + + public var expandedSQL: String? { + let cSQL = sqlite3_expanded_sql(statement) + defer { sqlite3_free(cSQL) } + guard let cSQL else { return nil } + return String(cString: cSQL) + } + + public func parameterCount() -> Int32 { + sqlite3_bind_parameter_count(statement) + } + + public func parameterIndexBy(_ name: String) -> Int32 { + sqlite3_bind_parameter_index(statement, name) + } + + public func parameterNameBy(_ index: Int32) -> String? { + sqlite3_bind_parameter_name(statement, index) + } + + public func bind(_ value: SQLiteValue, at index: Int32) throws(SQLiteError) { + let status = switch value { + case .int(let value): sqlite3_bind_int64(statement, index, value) + case .real(let value): sqlite3_bind_double(statement, index, value) + case .text(let value): sqlite3_bind_text(statement, index, value) + case .blob(let value): sqlite3_bind_blob(statement, index, value) + case .null: sqlite3_bind_null(statement, index) + } + if status != SQLITE_OK { + throw SQLiteError(connection) + } + } + + public func clearBindings() throws(SQLiteError) { + if sqlite3_clear_bindings(statement) != SQLITE_OK { + throw SQLiteError(connection) + } + } + + @discardableResult + public func step() throws(SQLiteError) -> Bool { + switch sqlite3_step(statement) { + case SQLITE_ROW: true + case SQLITE_DONE: false + default: throw SQLiteError(connection) + } + } + + public func reset() throws(SQLiteError) { + if sqlite3_reset(statement) != SQLITE_OK { + throw SQLiteError(connection) + } + } + + public func columnCount() -> Int32 { + sqlite3_column_count(statement) + } + + public func columnName(at index: Int32) -> String? { + sqlite3_column_name(statement, index) + } + + public func columnValue(at index: Int32) -> SQLiteValue { + switch sqlite3_column_type(statement, index) { + case SQLITE_INTEGER: .int(sqlite3_column_int64(statement, index)) + case SQLITE_FLOAT: .real(sqlite3_column_double(statement, index)) + case SQLITE_TEXT: .text(sqlite3_column_text(statement, index)) + case SQLITE_BLOB: .blob(sqlite3_column_blob(statement, index)) + default: .null + } + } +} + +// MARK: - Constants + +let SQLITE_STATIC = unsafeBitCast( + OpaquePointer(bitPattern: 0), + to: sqlite3_destructor_type.self +) + +let SQLITE_TRANSIENT = unsafeBitCast( + OpaquePointer(bitPattern: -1), + to: sqlite3_destructor_type.self +) + +// MARK: - Private Sunctions + +private func sqlite3_bind_parameter_name(_ stmt: OpaquePointer!, _ index: Int32) -> String? { + guard let cString = DataLiteC.sqlite3_bind_parameter_name(stmt, index) else { return nil } + return String(cString: cString) +} + +private func sqlite3_bind_text(_ stmt: OpaquePointer!, _ index: Int32, _ string: String) -> Int32 { + sqlite3_bind_text(stmt, index, string, -1, SQLITE_TRANSIENT) +} + +private func sqlite3_bind_blob(_ stmt: OpaquePointer!, _ index: Int32, _ data: Data) -> Int32 { + data.withUnsafeBytes { + sqlite3_bind_blob(stmt, index, $0.baseAddress, Int32($0.count), SQLITE_TRANSIENT) + } +} + +private func sqlite3_column_name(_ stmt: OpaquePointer!, _ iCol: Int32) -> String? { + guard let cString = DataLiteC.sqlite3_column_name(stmt, iCol) else { + return nil + } + return String(cString: cString) +} + +private func sqlite3_column_text(_ stmt: OpaquePointer!, _ iCol: Int32) -> String { + String(cString: DataLiteC.sqlite3_column_text(stmt, iCol)) +} + +private func sqlite3_column_blob(_ stmt: OpaquePointer!, _ iCol: Int32) -> Data { + Data( + bytes: sqlite3_column_blob(stmt, iCol), + count: Int(sqlite3_column_bytes(stmt, iCol)) + ) +} diff --git a/Sources/DataLiteCore/Docs.docc/Articles/CustomFunctions.md b/Sources/DataLiteCore/Docs.docc/Articles/CustomFunctions.md new file mode 100644 index 0000000..1651a66 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/CustomFunctions.md @@ -0,0 +1,144 @@ +# Extending SQLite with Custom Functions + +Build custom scalar and aggregate SQL functions that run inside SQLite. + +DataLiteCore lets you register custom SQL functions that participate in expressions, queries, and +aggregations just like built-in ones. Each function is registered on a specific connection and +becomes available to all statements executed through that connection. + +## Registering Functions + +Use ``ConnectionProtocol/add(function:)`` to register a function type on a connection. Pass the +function’s type, not an instance. DataLiteCore automatically manages function creation and +lifecycle — scalar functions are executed via their type, while aggregate functions are instantiated +per SQL invocation. + +```swift +try connection.add(function: Function.Regexp.self) // Built-in helper +try connection.add(function: Slugify.self) // Custom scalar function +``` + +To remove a registered function, call ``ConnectionProtocol/remove(function:)``. This is useful for +dynamic plug-ins or test environments that require a clean registration state. + +```swift +try connection.remove(function: Slugify.self) +``` + +## Implementing Scalar Functions + +Subclass ``Function/Scalar`` to define a function that returns a single value for each call. +Override the static metadata properties — ``Function/name``, ``Function/argc``, and +``Function/options`` — to declare the function’s signature, and implement its logic in +``Function/Scalar/invoke(args:)``. Return any type conforming to ``SQLiteRepresentable``. + +```swift +final class Slugify: Function.Scalar { + override class var name: String { "slugify" } + override class var argc: Int32 { 1 } + override class var options: Function.Options { [.deterministic, .innocuous] } + + override class func invoke(args: any ArgumentsProtocol) throws -> SQLiteRepresentable? { + guard let value = args[0] as String?, !value.isEmpty else { return nil } + return value.lowercased() + .replacingOccurrences(of: "\\W+", with: "-", options: .regularExpression) + .trimmingCharacters(in: .init(charactersIn: "-")) + } +} + +try connection.add(function: Slugify.self) +let rows = try connection.prepare(sql: "SELECT slugify(title) FROM articles") +``` + +## Implementing Aggregate Functions + +Aggregate functions maintain internal state across multiple rows. Subclass ``Function/Aggregate`` +and override ``Function/Aggregate/step(args:)`` to process each row and +``Function/Aggregate/finalize()`` to produce the final result. + +```swift +final class Median: Function.Aggregate { + private var values: [Double] = [] + + override class var name: String { "median" } + override class var argc: Int32 { 1 } + override class var options: Function.Options { [.deterministic] } + + override func step(args: any ArgumentsProtocol) throws { + if let value = args[0] as Double? { + values.append(value) + } + } + + override func finalize() throws -> SQLiteRepresentable? { + guard !values.isEmpty else { return nil } + let sorted = values.sorted() + let mid = sorted.count / 2 + return sorted.count.isMultiple(of: 2) + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] + } +} + +try connection.add(function: Median.self) +``` + +SQLite creates a new instance of an aggregate function for each aggregate expression in a query and +reuses it for all rows contributing to that result. It’s safe to store mutable state in instance +properties. + +## Handling Arguments and Results + +Custom functions receive input through an ``ArgumentsProtocol`` instance. Use subscripts to access +arguments by index and automatically convert them to Swift types. + +Two access forms are available: + +- `subscript(index: Index) -> SQLiteValue` — returns the raw SQLite value without conversion. +- `subscript(index: Index) -> T?`— converts the value to a Swift type + conforming to ``SQLiteRepresentable``. Returns `nil` if the argument is `NULL` or cannot be + converted. + +Use ``Function/Arguments/count`` to verify argument count before accessing elements. For +fine-grained decoding control, prefer the raw ``SQLiteValue`` form and handle conversion manually. + +```swift +override class func invoke(args: any ArgumentsProtocol) throws -> SQLiteRepresentable? { + guard args.count == 2 else { + throw SQLiteError(code: SQLITE_MISUSE, message: "expected two arguments") + } + guard let lhs = args[0] as Double?, let rhs = args[1] as Double? else { + return nil // returns SQL NULL if either argument is NULL + } + return lhs * rhs +} +``` + +Any type conforming to ``SQLiteRepresentable`` can be used both to read arguments and to return +results. Returning `nil` produces an SQL `NULL`. + +## Choosing Function Options + +Customize function characteristics via the ``Function/Options`` bitset: + +- ``Function/Options/deterministic`` — identical arguments always yield the same result, enabling + SQLite to cache calls and optimize query plans. +- ``Function/Options/directonly`` — restricts usage to trusted contexts (for example, disallows + calls from triggers or CHECK constraints). +- ``Function/Options/innocuous`` — marks the function as side-effect-free and safe for untrusted + SQL. + +Each scalar or aggregate subclass may return a different option set, depending on its behavior. + +## Error Handling + +Throwing from ``Function/Scalar/invoke(args:)``, ``Function/Aggregate/step(args:)``, or +``Function/Aggregate/finalize()`` propagates an error back to SQLite. DataLiteCore converts the +thrown error into a generic `SQLITE_ERROR` result code and uses its `localizedDescription` as the +message text. + +You can use this mechanism to signal both validation failures and runtime exceptions during function +execution. Throwing an error stops evaluation immediately and returns control to SQLite. + +- SeeAlso: ``Function`` +- SeeAlso: [Application-Defined SQL Functions](https://sqlite.org/appfunc.html) diff --git a/Sources/DataLiteCore/Docs.docc/Articles/DatabaseEncryption.md b/Sources/DataLiteCore/Docs.docc/Articles/DatabaseEncryption.md new file mode 100644 index 0000000..f41ff09 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/DatabaseEncryption.md @@ -0,0 +1,61 @@ +# Database Encryption + +Secure SQLite databases with SQLCipher encryption using DataLiteCore. + +DataLiteCore provides a clean API for applying and rotating SQLCipher encryption keys through the +connection interface. You can use it to unlock existing encrypted databases or to initialize new +ones securely before executing SQL statements. + +## Applying an Encryption Key + +Use ``ConnectionProtocol/apply(_:name:)`` to unlock an encrypted database file or to initialize +encryption on a new one. Supported key formats include: + +- ``Connection/Key/passphrase(_:)`` — a textual passphrase processed by SQLCipher’s key derivation. +- ``Connection/Key/rawKey(_:)`` — a 256-bit (`32`-byte) key supplied as `Data`. + +```swift +let connection = try Connection( + location: .file(path: "/path/to/sqlite.db"), + options: [.readwrite, .create, .fullmutex] +) +try connection.apply(.passphrase("vault-password"), name: nil) +``` + +The first call on a new database establishes encryption. If the database already exists and is +encrypted, the same call unlocks it for the current session. Plaintext files cannot be encrypted in +place. Always call ``ConnectionProtocol/apply(_:name:)`` immediately after opening the connection +and before executing any statements to avoid `SQLITE_NOTADB` errors. + +## Rotating Keys + +Use ``ConnectionProtocol/rekey(_:name:)`` to rewrite the database with a new key. The connection +must already be unlocked with the current key via ``ConnectionProtocol/apply(_:name:)``. + +```swift +let newKey = Data((0..<32).map { _ in UInt8.random(in: 0...UInt8.max) }) +try connection.rekey(.rawKey(newKey), name: nil) +``` + +Rekeying touches every page in the database and can take noticeable time on large files. Schedule +it during maintenance windows and be prepared for `SQLITE_BUSY` if other connections keep the file +locked. Adjust ``ConnectionProtocol/busyTimeout`` or coordinate access with application-level +locking. + +## Attached Databases + +When attaching additional databases, pass the attachment alias through the `name` parameter. +Use `nil` or `"main"` for the primary database, `"temp"` for the temporary one, and the alias for +others. + +```swift +try connection.execute(sql: "ATTACH DATABASE 'analytics.db' AS analytics") +try connection.apply(.passphrase("aux-password"), name: "analytics") +``` + +All databases attached to the same connection must follow a consistent encryption policy. If an +attached database must remain unencrypted, attach it using a separate connection instead. + +- SeeAlso: ``ConnectionProtocol/apply(_:name:)`` +- SeeAlso: ``ConnectionProtocol/rekey(_:name:)`` +- SeeAlso: [SQLCipher Documentation](https://www.zetetic.net/sqlcipher/documentation/) diff --git a/Sources/DataLiteCore/Docs.docc/Articles/ErrorHandling.md b/Sources/DataLiteCore/Docs.docc/Articles/ErrorHandling.md new file mode 100644 index 0000000..84c4ee7 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/ErrorHandling.md @@ -0,0 +1,65 @@ +# Handling SQLite Errors + +Handle SQLite errors predictably with DataLiteCore. + +DataLiteCore converts all SQLite failures into an ``SQLiteError`` structure that contains both the +extended result code and a descriptive message. This unified error model lets you accurately +distinguish between constraint violations, locking issues, and other failure categories — while +preserving full diagnostic information for recovery and logging. + +## SQLiteError Breakdown + +``SQLiteError`` exposes fields that help with diagnostics and recovery: + +- ``SQLiteError/code`` — the extended SQLite result code (for example, + `SQLITE_CONSTRAINT_FOREIGNKEY` or `SQLITE_BUSY_TIMEOUT`). Use it for programmatic + branching — e.g., retry logic, rollbacks, or user-facing messages. +- ``SQLiteError/message`` — a textual description of the underlying SQLite failure. + +Since ``SQLiteError`` conforms to `CustomStringConvertible`, you can log it directly. For +user-facing alerts, derive your own localized messages from the error code instead of exposing +SQLite messages verbatim. + +## Typed Throws + +Most DataLiteCore APIs are annotated as `throws(SQLiteError)`, meaning they only throw SQLiteError +instances. + +Only APIs that execute arbitrary user code or integrate with external systems may surface other +error types. Consult the documentation on each API for specific details. + +```swift +do { + try connection.execute(sql: """ + INSERT INTO users(email) VALUES ('ada@example.com') + """) +} catch { + switch error.code { + case SQLITE_CONSTRAINT: + showAlert("A user with this email already exists.") + case SQLITE_BUSY, SQLITE_LOCKED: + retryLater() + default: + print("Unexpected error: \(error.message)") + } +} +``` + +## Multi-Statement Scenarios + +- ``ConnectionProtocol/execute(sql:)`` stops at the first failing statement and propagates its + ``SQLiteError``. +- ``StatementProtocol/execute(_:)`` reuses prepared statements; inside `catch` blocks, remember to + call ``StatementProtocol/reset()`` and (if needed) ``StatementProtocol/clearBindings()`` before + retrying. +- When executing multiple statements, add your own logging if you need to know which one + failed — the propagated ``SQLiteError`` reflects SQLite’s diagnostics only. + +## Custom Functions + +Errors thrown from ``Function/Scalar`` or ``Function/Aggregate`` implementations are reported back +to SQLite as `SQLITE_ERROR`, with the error’s `localizedDescription` as the message text. +Define clear, domain-specific error types to make SQL traces and logs more meaningful. + +- SeeAlso: ``SQLiteError`` +- SeeAlso: [SQLite Result Codes](https://sqlite.org/rescode.html) diff --git a/Sources/DataLiteCore/Docs.docc/Articles/Multithreading.md b/Sources/DataLiteCore/Docs.docc/Articles/Multithreading.md new file mode 100644 index 0000000..b03236c --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/Multithreading.md @@ -0,0 +1,94 @@ +# Multithreading Strategies + +Coordinate SQLite safely across queues, actors, and Swift concurrency using DataLiteCore. + +SQLite remains fundamentally serialized, so deliberate connection ownership and scheduling are +essential for correctness and performance. DataLiteCore does not include a built-in connection +pool, but its deterministic behavior and configuration options allow you to design synchronization +strategies that match your workload. + +## Core Guidelines + +- **One connection per queue or actor**. Keep each ``Connection`` confined to a dedicated serial + `DispatchQueue` or an `actor` to ensure ordered execution and predictable statement lifecycles. +- **Do not share statements across threads**. ``Statement`` instances are bound to their parent + ``Connection`` and are not thread-safe. +- **Scale with multiple connections**. For concurrent workloads, use a dedicated writer connection + alongside a pool of readers so long-running transactions don’t block unrelated operations. + +```swift +actor Database { + private let connection: Connection + + init(path: String) throws { + connection = try Connection( + path: path, + options: [.readwrite, .create, .fullmutex] + ) + connection.busyTimeout = 5_000 // wait up to 5 seconds for locks + } + + func insertUser(name: String) throws { + let statement = try connection.prepare( + sql: "INSERT INTO users(name) VALUES (?)" + ) + try statement.bind(name, at: 1) + try statement.step() + } +} +``` + +Encapsulating database work in an `actor` or serial queue aligns naturally with Swift Concurrency +while maintaining safe access to SQLite’s synchronous API. + +## Synchronization Options + +- ``Connection/Options/nomutex`` — disables SQLite’s internal mutexes (multi-thread mode). Each + connection must be accessed by only one thread at a time. + + ```swift + let connection = try Connection( + location: .file(path: "/path/to/sqlite.db"), + options: [.readwrite, .nomutex] + ) + ``` + +- ``Connection/Options/fullmutex`` — enables serialized mode with full internal locking. A single + ``Connection`` may be shared across threads, but global locks reduce throughput. + + ```swift + let connection = try Connection( + location: .file(path: "/path/to/sqlite.db"), + options: [.readwrite, .fullmutex] + ) + ``` + +SQLite defaults to serialized mode, but concurrent writers still contend for locks. Plan long +transactions carefully and adjust ``ConnectionProtocol/busyTimeout`` to handle `SQLITE_BUSY` +conditions gracefully. + +- SeeAlso: [Using SQLite In Multi-Threaded Applications](https://sqlite.org/threadsafe.html) + +## Delegates and Side Effects + +``ConnectionDelegate`` and ``ConnectionTraceDelegate`` callbacks execute synchronously on SQLite’s +internal thread. Keep them lightweight and non-blocking. Offload work to another queue when +necessary to prevent deadlocks or extended lock holds. + +```swift +final class Logger: ConnectionTraceDelegate { + private let queue = DispatchQueue(label: "logging") + + func connection( + _ connection: ConnectionProtocol, + trace sql: ConnectionTraceDelegate.Trace + ) { + queue.async { + print("SQL:", sql.expandedSQL) + } + } +} +``` + +This pattern keeps tracing responsive and prevents SQLite’s internal thread from being blocked by +slow I/O or external operations. diff --git a/Sources/DataLiteCore/Docs.docc/Articles/PreparedStatements.md b/Sources/DataLiteCore/Docs.docc/Articles/PreparedStatements.md new file mode 100644 index 0000000..2412ed9 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/PreparedStatements.md @@ -0,0 +1,192 @@ +# Mastering Prepared Statements + +Execute SQL efficiently and safely with reusable prepared statements in DataLiteCore. + +Prepared statements allow you to compile SQL once, bind parameters efficiently, and execute it +multiple times without re-parsing or re-planning. The ``Statement`` type in DataLiteCore is a thin, +type-safe wrapper around SQLite’s C API that manages the entire lifecycle of a compiled SQL +statement — from preparation and parameter binding to execution and result retrieval. Statements are +automatically finalized when no longer referenced, ensuring predictable resource cleanup. + +## Preparing Statements + +Use ``ConnectionProtocol/prepare(sql:)`` or ``ConnectionProtocol/prepare(sql:options:)`` to create +a compiled ``StatementProtocol`` instance ready for parameter binding and execution. + +The optional ``Statement/Options`` control how the database engine optimizes the compilation and +reuse of a prepared statement. For example, ``Statement/Options/persistent`` marks the statement as +suitable for long-term reuse, allowing the engine to optimize memory allocation for statements +expected to remain active across multiple executions. ``Statement/Options/noVtab`` restricts the use +of virtual tables during preparation, preventing them from being referenced at compile time. + +```swift +let statement = try connection.prepare( + sql: """ + SELECT id, email + FROM users + WHERE status = :status + AND updated_at >= ? + """, + options: [.persistent] +) +``` + +Implementations of ``StatementProtocol`` are responsible for managing the lifetime of their +underlying database resources. The default ``Statement`` type provided by DataLiteCore automatically +finalizes statements when their last strong reference is released, ensuring deterministic cleanup +through Swift’s memory management. + +## Managing the Lifecycle + +Use ``StatementProtocol/reset()`` to return a statement to its initial state so that it can be +executed again. Call ``StatementProtocol/clearBindings()`` to remove all previously bound parameter +values, allowing the same prepared statement to be reused with completely new input data. Always +use these methods before reusing a prepared statement. + +```swift +try statement.reset() +try statement.clearBindings() +``` + +## Binding Parameters + +You can bind either raw ``SQLiteValue`` values or any Swift type that conforms to ``SQLiteBindable`` +or ``SQLiteRepresentable``. Parameter placeholders in the SQL statement are assigned numeric indexes +in the order they appear, starting from `1`. + +To inspect or debug parameter mappings, use ``StatementProtocol/parameterCount()`` to check the +total number of parameters, or ``StatementProtocol/parameterNameBy(_:)`` to retrieve the name of a +specific placeholder by its index. + +### Binding by Index + +Use positional placeholders to bind parameters by numeric index. A simple `?` placeholder is +automatically assigned the next available index in the order it appears, starting from `1`. +A numbered placeholder (`?NNN`) explicitly defines its own index within the statement, letting you +bind parameters out of order if needed. + +```swift +let insertLog = try connection.prepare(sql: """ + INSERT INTO logs(level, message, created_at) + VALUES (?, ?, ?) +""") + +try insertLog.bind("info", at: 1) +try insertLog.bind("Cache warmed", at: 2) +try insertLog.bind(Date(), at: 3) +try insertLog.step() // executes the INSERT +try insertLog.reset() +``` + +### Binding by Name + +Named placeholders (`:name`, `@name`, `$name`) improve readability and allow the same parameter to +appear multiple times within a statement. When binding, pass the full placeholder token — including +its prefix — to the ``StatementProtocol/bind(_:by:)-(SQLiteValue,_)`` method. + +```swift +let usersByStatus = try connection.prepare(sql: """ + SELECT id, email + FROM users + WHERE status = :status + AND email LIKE :pattern +""") + +try usersByStatus.bind("active", by: ":status") +try usersByStatus.bind("%@example.com", by: ":pattern") +``` + +If you need to inspect the numeric index associated with a named parameter, use +``StatementProtocol/parameterIndexBy(_:)``. This can be useful for diagnostics, logging, or +integrating with utility layers that operate by index. + +### Reusing Parameters + +When the same named placeholder appears multiple times in a statement, SQLite internally assigns all +of them to a single binding slot. This means you only need to set the value once, and it will be +applied everywhere that placeholder occurs. + +```swift +let sales = try connection.prepare(sql: """ + SELECT id + FROM orders + WHERE customer_id = :client + OR created_by = :client + LIMIT :limit +""") + +try sales.bind(42, by: ":client") // used for both conditions +try sales.bind(50, by: ":limit") +``` + +### Mixing Placeholders + +You can freely combine named and positional placeholders within the same statement. SQLite assigns +numeric indexes to all placeholders in the order they appear, regardless of whether they are named +or positional. To keep bindings predictable, it’s best to follow a consistent style within each +statement. + +```swift +let search = try connection.prepare(sql: """ + SELECT id, title + FROM articles + WHERE category_id IN (?, ?, ?) + AND published_at >= :since +""") + +try search.bind(3, at: 1) +try search.bind(5, at: 2) +try search.bind(8, at: 3) +try search.bind(Date(timeIntervalSinceNow: -7 * 24 * 60 * 60), by: ":since") +``` + +## Executing Statements + +Advance execution with ``StatementProtocol/step()``. This method returns `true` while rows are +available, and `false` when the statement is fully consumed — or immediately, for statements that +do not produce results. + +Always reset a statement before re-executing it; otherwise, the database engine will report a misuse +error. + +```swift +var rows: [SQLiteRow] = [] +while try usersByStatus.step() { + if let row = usersByStatus.currentRow() { + rows.append(row) + } +} + +try usersByStatus.reset() +try usersByStatus.clearBindings() +``` + +For bulk operations, use ``StatementProtocol/execute(_:)``. It accepts an array of ``SQLiteRow`` +values and automatically performs binding, stepping, clearing, and resetting in a loop — making it +convenient for batch inserts or updates. + +## Fetching Result Data + +Use ``StatementProtocol/columnCount()`` and ``StatementProtocol/columnName(at:)`` to inspect the +structure of the result set. Retrieve individual column values with +``StatementProtocol/columnValue(at:)->SQLiteValue`` — either as a raw ``SQLiteValue`` or as a typed +value conforming to ``SQLiteRepresentable``. Alternatively, use ``StatementProtocol/currentRow()`` +to obtain the full set of column values for the current result row. + +```swift +while try statement.step() { + guard let identifier: Int64 = statement.columnValue(at: 0), + let email: String = statement.columnValue(at: 1) + else { continue } + print("User \(identifier): \(email)") +} +try statement.reset() +``` + +Each row returned by `currentRow()` is an independent copy of the current result data. You can +safely store it, transform it into a domain model, or reuse its values as parameters in subsequent +statements through ``StatementProtocol/bind(_:)``. + +- SeeAlso: ``StatementProtocol`` +- SeeAlso: ``Statement`` +- SeeAlso: [SQLite Prepared Statements](https://sqlite.org/c3ref/stmt.html) diff --git a/Sources/DataLiteCore/Docs.docc/Articles/SQLiteRows.md b/Sources/DataLiteCore/Docs.docc/Articles/SQLiteRows.md new file mode 100644 index 0000000..f0f3068 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/SQLiteRows.md @@ -0,0 +1,142 @@ +# Working with SQLiteRow + +Represent SQL rows and parameters with SQLiteRow. + +``SQLiteRow`` is an ordered container for column/value pairs. It preserves insertion order—matching +the schema when representing result sets—and provides helpers for column names, named parameters, +and literal rendering. + +## Creating Rows + +Initialize a row with a dictionary literal or assign values incrementally through subscripting. +Values can be ``SQLiteValue`` instances or any type convertible via ``SQLiteRepresentable``. + +```swift +var payload: SQLiteRow = [ + "username": .text("ada"), + "email": "ada@example.com".sqliteValue, + "is_admin": false.sqliteValue +] + +payload["last_login_at"] = Int64(Date().timeIntervalSince1970).sqliteValue +``` + +``SQLiteRow/columns`` returns the ordered column names, and ``SQLiteRow/namedParameters`` provides +matching tokens (prefixed with `:`) suitable for parameterized SQL. + +```swift +print(payload.columns) // ["username", "email", "is_admin", "last_login_at"] +print(payload.namedParameters) // [":username", ":email", ":is_admin", ":last_login_at"] +``` + +## Generating SQL Fragments + +Use row metadata to build SQL snippets without manual string concatenation: + +```swift +let columns = payload.columns.joined(separator: ", ") +let placeholders = payload.namedParameters.joined(separator: ", ") +let assignments = zip(payload.columns, payload.namedParameters) + .map { "\($0) = \($1)" } + .joined(separator: ", ") + +// columns -> "username, email, is_admin, last_login_at" +// placeholders -> ":username, :email, :is_admin, :last_login_at" +// assignments -> "username = :username, ..." +``` + +When generating migrations or inserting literal values, ``SQLiteValue/sqliteLiteral`` renders safe +SQL fragments for numeric and text values. Always escape identifiers manually if column names come +from untrusted input. + +## Inserting Rows + +Bind an entire row to a statement using ``StatementProtocol/bind(_:)``. The method matches column +names to identically named placeholders. + +```swift +var user: SQLiteRow = [ + "username": .text("ada"), + "email": .text("ada@example.com"), + "created_at": Int64(Date().timeIntervalSince1970).sqliteValue +] + +let insertSQL = """ +INSERT INTO users (\(user.columns.joined(separator: ", "))) +VALUES (\(user.namedParameters.joined(separator: ", "))) +""" + +let insert = try connection.prepare(sql: insertSQL) +try insert.bind(user) +try insert.step() +try insert.reset() +``` + +To insert multiple rows, prepare an array of ``SQLiteRow`` values and call +``StatementProtocol/execute(_:)``. The helper performs binding, stepping, and clearing for each row: + +```swift +let batch: [SQLiteRow] = [ + ["username": .text("ada"), "email": .text("ada@example.com")], + ["username": .text("grace"), "email": .text("grace@example.com")] +] + +try insert.execute(batch) +``` + +## Updating Rows + +Because ``SQLiteRow`` is a value type, you can duplicate and extend it for related operations such +as building `SET` clauses or constructing `WHERE` conditions. + +```swift +var changes: SQLiteRow = [ + "email": .text("ada@new.example"), + "last_login_at": Int64(Date().timeIntervalSince1970).sqliteValue +] + +let setClause = zip(changes.columns, changes.namedParameters) + .map { "\($0) = \($1)" } + .joined(separator: ", ") + +var parameters = changes +parameters["id"] = .int(1) + +let update = try connection.prepare(sql: """ + UPDATE users + SET \(setClause) + WHERE id = :id +""") + +try update.bind(parameters) +try update.step() +``` + +## Reading Rows + +``StatementProtocol/currentRow()`` returns an ``SQLiteRow`` snapshot of the current result. Use it +to pass data through mapping layers or transform results lazily without immediate conversion: + +```swift +let statement = try connection.prepare(sql: "SELECT id, email FROM users LIMIT 10") + +var rows: [SQLiteRow] = [] +while try statement.step() { + if let row = statement.currentRow() { + rows.append(row) + } +} +``` + +You can iterate over a row’s columns via `columns`, and subscript by name to retrieve stored values. +For typed access, cast through ``SQLiteValue`` or adopt ``SQLiteRepresentable`` in your custom +types. + +## Diagnostics + +Use ``SQLiteRow/description`` to log payloads during development. For security-sensitive logs, +redact or whitelist keys before printing. Because rows preserve order, logs mirror the schema +defined in your SQL, making comparisons straightforward. + +- SeeAlso: ``SQLiteRow`` +- SeeAlso: ``StatementProtocol`` diff --git a/Sources/DataLiteCore/Docs.docc/Articles/WorkingWithConnections.md b/Sources/DataLiteCore/Docs.docc/Articles/WorkingWithConnections.md new file mode 100644 index 0000000..630fef9 --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/Articles/WorkingWithConnections.md @@ -0,0 +1,188 @@ +# Working with Connections + +Open, configure, monitor, and transact with SQLite connections using DataLiteCore. + +Establishing and configuring a ``Connection`` is the first step before executing SQL statements +with **DataLiteCore**. A connection wraps the underlying SQLite handle, exposes ergonomic Swift +APIs, and provides hooks for observing database activity. + +## Opening a Connection + +Create a connection with ``Connection/init(location:options:)``. The initializer opens (or creates) +the target database file and registers lifecycle hooks that enable tracing, update notifications, +and transaction callbacks. + +Call ``ConnectionProtocol/initialize()`` once during application start-up when you need to ensure +the SQLite core has been initialized manually—for example, when linking SQLite dynamically or when +the surrounding framework does not do it on your behalf. Pair it with +``ConnectionProtocol/shutdown()`` during application tear-down if you require full control of +SQLite's global state. + +```swift +import DataLiteCore + +do { + try Connection.initialize() + let connection = try Connection( + location: .file(path: "/path/to/sqlite.db"), + options: [.readwrite, .create] + ) + // Execute SQL or configure PRAGMAs +} catch { + print("Failed to open database: \(error)") +} +``` + +### Choosing a Location + +Pick the database storage strategy from the ``Connection/Location`` enumeration: + +- ``Connection/Location/file(path:)`` — a persistent on-disk file or URI backed database. +- ``Connection/Location/inMemory`` — a pure in-memory database that disappears once the connection + closes. +- ``Connection/Location/temporary`` — a transient on-disk file that SQLite removes when the session + ends. + +### Selecting Options + +Control how the connection is opened with ``Connection/Options``. Combine flags to describe the +required access mode, locking policy, and URI behavior. + +```swift +let connection = try Connection( + location: .inMemory, + options: [.readwrite, .nomutex] +) +``` + +Common combinations include: + +- ``Connection/Options/readwrite`` + ``Connection/Options/create`` — read/write access that creates + the file if missing. +- ``Connection/Options/readonly`` — read-only access preventing accidental writes. +- ``Connection/Options/fullmutex`` — enables serialized mode for multi-threaded access. +- ``Connection/Options/uri`` — allows SQLite URI parameters, such as query string pragmas. + +- SeeAlso: [Opening A New Database Connection](https://sqlite.org/c3ref/open.html) +- SeeAlso: [In-Memory Databases](https://sqlite.org/inmemorydb.html) + +## Closing a Connection + +``Connection`` automatically closes the underlying SQLite handle when the instance is deallocated. +This ensures resources are released even when the object leaves scope unexpectedly. For long-lived +applications, prefer explicit lifecycle management—store the connection in a dedicated component +and release it deterministically when you are done to avoid keeping file locks or WAL checkpoints +around unnecessarily. + +If your application called ``ConnectionProtocol/shutdown()`` to clean up global state, make sure all +connections have been released before invoking it. + +## Managing Transactions + +Manage transactional work with ``ConnectionProtocol/beginTransaction(_:)``, +``ConnectionProtocol/commitTransaction()``, and ``ConnectionProtocol/rollbackTransaction()``. When +you do not start a transaction explicitly, SQLite runs in autocommit mode and executes each +statement in its own transaction. + +```swift +do { + try connection.beginTransaction(.immediate) + try connection.execute(sql: "INSERT INTO users (name) VALUES ('Ada')") + try connection.execute(sql: "INSERT INTO users (name) VALUES ('Grace')") + try connection.commitTransaction() +} catch { + try? connection.rollbackTransaction() + throw error +} +``` + +``TransactionType`` controls when SQLite acquires locks: + +- ``TransactionType/deferred`` — defers locking until the first read or write; this is the default. +- ``TransactionType/immediate`` — immediately takes a RESERVED lock to prevent other writers. +- ``TransactionType/exclusive`` — escalates to an EXCLUSIVE lock and, in `DELETE` journal mode, + blocks readers. + +``ConnectionProtocol/beginTransaction(_:)`` uses `.deferred` by default. When +``ConnectionProtocol/isAutocommit`` returns `false`, a transaction is already active. Calling +`beginTransaction` again raises an error, so guard composite operations accordingly. + +- SeeAlso: [Transaction](https://sqlite.org/lang_transaction.html) + +## PRAGMA Parameters + +Most frequently used PRAGMA directives are modeled as direct properties on ``ConnectionProtocol``: +``ConnectionProtocol/busyTimeout``, ``ConnectionProtocol/applicationID``, +``ConnectionProtocol/foreignKeys``, ``ConnectionProtocol/journalMode``, +``ConnectionProtocol/synchronous``, and ``ConnectionProtocol/userVersion``. Update them directly on +an active connection: + +```swift +connection.userVersion = 2024 +connection.foreignKeys = true +connection.journalMode = .wal +``` + +### Custom PRAGMAs + +Use ``ConnectionProtocol/get(pragma:)`` and ``ConnectionProtocol/set(pragma:value:)`` for PRAGMAs +that do not have a dedicated API. They accept ``Pragma`` values (string literal expressible) and +any type that conforms to ``SQLiteRepresentable``. `set` composes a `PRAGMA = ` +statement, while `get` issues `PRAGMA `. + +```swift +// Read the current cache_size value +let cacheSize: Int32? = try connection.get(pragma: "cache_size") + +// Enable WAL journaling and adjust the sync mode +try connection.set(pragma: .journalMode, value: JournalMode.wal) +try connection.set(pragma: .synchronous, value: Synchronous.normal) +``` + +The `value` parameter automatically converts to ``SQLiteValue`` through ``SQLiteRepresentable``, +so you can pass `Bool`, `Int`, `String`, `Synchronous`, `JournalMode`, or a custom type that +supports the protocol. + +- SeeAlso: [PRAGMA Statements](https://sqlite.org/pragma.html) + +## Observing Connection Events + +``ConnectionDelegate`` lets you observe connection-level events such as row updates, commits, and +rollbacks. Register a delegate with ``ConnectionProtocol/add(delegate:)``. Delegates are stored +weakly, so you are responsible for managing their lifetime. Remove a delegate with +``ConnectionProtocol/remove(delegate:)`` when it is no longer required. + +Use ``ConnectionTraceDelegate`` to receive SQL statement traces and register it with +``ConnectionProtocol/add(trace:)``. Trace delegates are also held weakly. + +```swift +final class QueryLogger: ConnectionDelegate, ConnectionTraceDelegate { + func connection(_ connection: ConnectionProtocol, trace sql: ConnectionTraceDelegate.Trace) { + print("SQL:", sql.expandedSQL) + } + + func connection(_ connection: ConnectionProtocol, didUpdate action: SQLiteAction) { + print("Change:", action) + } + + func connectionWillCommit(_ connection: ConnectionProtocol) throws { + try validatePendingOperations() + } + + func connectionDidRollback(_ connection: ConnectionProtocol) { + resetInMemoryCache() + } +} + +let logger = QueryLogger() +connection.add(delegate: logger) +connection.add(trace: logger) + +// ... + +connection.remove(trace: logger) +connection.remove(delegate: logger) +``` + +All callbacks execute synchronously on SQLite's internal thread. Keep delegate logic lightweight, +avoid blocking I/O, and hand heavy work off to other queues when necessary to preserve responsiveness. diff --git a/Sources/DataLiteCore/Docs.docc/DataLiteCore.md b/Sources/DataLiteCore/Docs.docc/DataLiteCore.md new file mode 100644 index 0000000..712947c --- /dev/null +++ b/Sources/DataLiteCore/Docs.docc/DataLiteCore.md @@ -0,0 +1,19 @@ +# ``DataLiteCore`` + +**DataLiteCore** is an intuitive library for working with SQLite in Swift applications. + +**DataLiteCore** provides an object-oriented API on top of the C interface, making it simple to +integrate SQLite capabilities into your projects. The library combines powerful database +management and SQL execution features with the ergonomics and flexibility of native Swift code. + +## Topics + +### Articles + +- +- +- +- +- +- +- diff --git a/Sources/DataLiteCore/Enums/JournalMode.swift b/Sources/DataLiteCore/Enums/JournalMode.swift new file mode 100644 index 0000000..88c5e8a --- /dev/null +++ b/Sources/DataLiteCore/Enums/JournalMode.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Represents the journal modes available for an SQLite database. +/// +/// The journal mode determines how the database handles transactions and how it maintains the +/// journal for rollback and recovery. +/// +/// - SeeAlso: [journal_mode](https://sqlite.org/pragma.html#pragma_journal_mode) +public enum JournalMode: String, SQLiteRepresentable { + /// DELETE journal mode. + /// + /// This is the default behavior. The rollback journal is deleted at the conclusion of each + /// transaction. The delete operation itself causes the transaction to commit. + /// + /// - SeeAlso: [Atomic Commit In SQLite](https://sqlite.org/atomiccommit.html) + case delete + + /// TRUNCATE journal mode. + /// + /// In this mode, the rollback journal is truncated to zero length at the end of each + /// transaction instead of being deleted. On many systems, truncating a file is much faster than + /// deleting it because truncating does not require modifying the containing directory. + case truncate + + /// PERSIST journal mode. + /// + /// In this mode, the rollback journal is not deleted at the end of each transaction. Instead, + /// the header of the journal is overwritten with zeros. This prevents other database + /// connections from rolling the journal back. The PERSIST mode is useful as an optimization on + /// platforms where deleting or truncating a file is more expensive than overwriting the first + /// block of a file with zeros. + /// + /// - SeeAlso: [journal_size_limit]( + /// https://sqlite.org/pragma.html#pragma_journal_size_limit) + case persist + + /// MEMORY journal mode. + /// + /// In this mode, the rollback journal is stored entirely in volatile RAM rather than on disk. + /// This saves disk I/O but at the expense of database safety and integrity. If the application + /// crashes during a transaction, the database file will likely become corrupt. + case memory + + /// Write-Ahead Logging (WAL) journal mode. + /// + /// This mode uses a write-ahead log instead of a rollback journal to implement transactions. + /// The WAL mode is persistent, meaning it stays in effect across multiple database connections + /// and persists even after closing and reopening the database. + /// + /// - SeeAlso: [Write-Ahead Logging](https://sqlite.org/wal.html) + case wal + + /// OFF journal mode. + /// + /// In this mode, the rollback journal is completely disabled, meaning no rollback journal is + /// ever created. This disables SQLite's atomic commit and rollback capabilities. The `ROLLBACK` + /// command will no longer work and behaves in an undefined way. Applications must avoid using + /// the `ROLLBACK` command when the journal mode is OFF. If the application crashes in the + /// middle of a transaction, the database file will likely become corrupt, as there is no way to + /// unwind partially completed operations. For example, if a duplicate entry causes a + /// `CREATE UNIQUE INDEX` statement to fail halfway through, it will leave behind a partially + /// created index, resulting in a corrupted database state. + case off + + /// The string representation of the journal mode recognized by SQLite. + /// + /// Each case maps to its corresponding uppercase string value expected by SQLite. For example, + /// `.wal` maps to `"WAL"`. This value is typically used when reading or setting the journal mode + /// through the `PRAGMA journal_mode` command. + /// + /// - Returns: The uppercase string identifier of the journal mode as understood by SQLite. + /// + /// - SeeAlso: [journal_mode](https://sqlite.org/pragma.html#pragma_journal_mode) + public var rawValue: String { + switch self { + case .delete: "DELETE" + case .truncate: "TRUNCATE" + case .persist: "PERSIST" + case .memory: "MEMORY" + case .wal: "WAL" + case .off: "OFF" + } + } + + /// Creates a `JournalMode` instance from a string representation. + /// + /// The initializer performs a case-insensitive match between the provided string and the known + /// SQLite journal mode names. If the input does not correspond to any valid journal mode, the + /// initializer returns `nil`. + /// + /// - Parameter rawValue: The string name of the journal mode, as defined by SQLite. + /// - Returns: A `JournalMode` value if the input string matches a supported mode; otherwise, + /// `nil`. + /// + /// - SeeAlso: [journal_mode](https://sqlite.org/pragma.html#pragma_journal_mode) + public init?(rawValue: String) { + switch rawValue.uppercased() { + case "DELETE": self = .delete + case "TRUNCATE": self = .truncate + case "PERSIST": self = .persist + case "MEMORY": self = .memory + case "WAL": self = .wal + case "OFF": self = .off + default: return nil + } + } +} diff --git a/Sources/DataLiteCore/Enums/SQLiteAction.swift b/Sources/DataLiteCore/Enums/SQLiteAction.swift new file mode 100644 index 0000000..9d3f87b --- /dev/null +++ b/Sources/DataLiteCore/Enums/SQLiteAction.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Represents a type of database change operation. +/// +/// The `SQLiteAction` enumeration describes an action that modifies a database table. It +/// distinguishes between row insertions, updates, and deletions, providing context such +/// as the database name, table, and affected row ID. +/// +/// - SeeAlso: [Data Change Notification Callbacks](https://sqlite.org/c3ref/update_hook.html) +public enum SQLiteAction { + /// A new row was inserted into a table. + /// + /// - Parameters: + /// - db: The name of the database where the insertion occurred. + /// - table: The name of the table into which the row was inserted. + /// - rowID: The row ID of the newly inserted row. + case insert(db: String, table: String, rowID: Int64) + + /// An existing row was modified in a table. + /// + /// - Parameters: + /// - db: The name of the database where the update occurred. + /// - table: The name of the table containing the updated row. + /// - rowID: The row ID of the modified row. + case update(db: String, table: String, rowID: Int64) + + /// A row was deleted from a table. + /// + /// - Parameters: + /// - db: The name of the database where the deletion occurred. + /// - table: The name of the table from which the row was deleted. + /// - rowID: The row ID of the deleted row. + case delete(db: String, table: String, rowID: Int64) +} diff --git a/Sources/DataLiteCore/Enums/SQLiteValue.swift b/Sources/DataLiteCore/Enums/SQLiteValue.swift new file mode 100644 index 0000000..8c5dc62 --- /dev/null +++ b/Sources/DataLiteCore/Enums/SQLiteValue.swift @@ -0,0 +1,65 @@ +import Foundation + +/// An enumeration that represents raw SQLite values. +/// +/// `SQLiteValue` encapsulates all fundamental SQLite storage classes. It is used to +/// store values retrieved from or written to a SQLite database, providing a type-safe +/// Swift representation for each supported data type. +/// +/// - SeeAlso: [Datatypes In SQLite](https://sqlite.org/datatype3.html) +/// +/// ## Topics +/// +/// ### Enumeration Cases +/// +/// - ``int(_:)`` +/// - ``real(_:)`` +/// - ``text(_:)`` +/// - ``blob(_:)`` +/// - ``null`` +public enum SQLiteValue: Equatable, Hashable, Sendable { + /// A 64-bit integer value. + case int(Int64) + + /// A double-precision floating-point value. + case real(Double) + + /// A text string encoded in UTF-8. + case text(String) + + /// Binary data (BLOB). + case blob(Data) + + /// A `NULL` value. + case null +} + +public extension SQLiteValue { + /// A SQL literal representation of the value. + /// + /// Converts the current value into a string suitable for embedding directly in an SQL + /// statement. Strings are quoted and escaped, binary data is encoded in hexadecimal form, and + /// `NULL` is represented by the literal `NULL`. + var sqliteLiteral: String { + switch self { + case .int(let int): "\(int)" + case .real(let real): "\(real)" + case .text(let text): "'\(text.replacingOccurrences(of: "'", with: "''"))'" + case .blob(let data): "X'\(data.hex)'" + case .null: "NULL" + } + } +} + +extension SQLiteValue: CustomStringConvertible { + /// A textual representation of the value, identical to `sqliteLiteral`. + public var description: String { + sqliteLiteral + } +} + +private extension Data { + var hex: String { + map { String(format: "%02hhX", $0) }.joined() + } +} diff --git a/Sources/DataLiteCore/Enums/Synchronous.swift b/Sources/DataLiteCore/Enums/Synchronous.swift new file mode 100644 index 0000000..2e21a92 --- /dev/null +++ b/Sources/DataLiteCore/Enums/Synchronous.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Represents the available synchronous modes for an SQLite database. +/// +/// The synchronous mode controls how thoroughly SQLite ensures that data is physically written to +/// disk. It defines the balance between durability, consistency, and performance during commits. +/// +/// - SeeAlso: [PRAGMA synchronous](https://sqlite.org/pragma.html#pragma_synchronous) +public enum Synchronous: UInt8, SQLiteRepresentable { + /// Disables synchronization for maximum performance. + /// + /// With `synchronous=OFF`, SQLite does not wait for data to reach non-volatile storage before + /// continuing. The database may become inconsistent if the operating system crashes or power is + /// lost, although application-level crashes do not cause corruption. + /// Best suited for temporary databases or rebuildable data. + case off = 0 + + /// Enables normal synchronization. + /// + /// SQLite performs syncs only at critical points. In WAL mode, this guarantees consistency but + /// not full durability: the most recent transactions might be lost after a power failure. In + /// rollback journal mode, there is a very small chance of corruption on older filesystems. + /// Recommended for most use cases where performance is preferred over strict durability. + case normal = 1 + + /// Enables full synchronization. + /// + /// SQLite calls the VFS `xSync` method to ensure that all data is written to disk before + /// continuing. Prevents corruption even after a system crash or power loss. Default mode for + /// rollback journals and fully ACID-compliant in WAL mode. Provides strong consistency and + /// isolation; durability may depend on filesystem behavior. + case full = 2 + + /// Enables extra synchronization for maximum durability. + /// + /// Extends `FULL` by also syncing the directory that contained the rollback journal after it + /// is removed, ensuring durability even if power is lost immediately after a commit. Guarantees + /// full ACID compliance in both rollback and WAL modes. Recommended for systems where + /// durability is more important than performance. + case extra = 3 +} diff --git a/Sources/DataLiteCore/Enums/TransactionType.swift b/Sources/DataLiteCore/Enums/TransactionType.swift new file mode 100644 index 0000000..02de957 --- /dev/null +++ b/Sources/DataLiteCore/Enums/TransactionType.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Represents the transaction modes supported by SQLite. +/// +/// A transaction defines how the database manages concurrency and locking. The transaction type +/// determines when a write lock is acquired and how other connections can access the database +/// during the transaction. +/// +/// - SeeAlso: [Transaction](https://sqlite.org/lang_transaction.html) +public enum TransactionType: String, CustomStringConvertible { + /// Defers the start of the transaction until the first database access. + /// + /// With `BEGIN DEFERRED`, no locks are acquired immediately. If the first statement is a read + /// (`SELECT`), a read transaction begins. If it is a write statement, a write transaction + /// begins instead. Deferred transactions allow greater concurrency and are the default mode. + case deferred + + /// Starts a write transaction immediately. + /// + /// With `BEGIN IMMEDIATE`, a reserved lock is acquired right away to ensure that no other + /// connection can start a conflicting write. The statement may fail with `SQLITE_BUSY` if + /// another write transaction is already active. + case immediate + + /// Starts an exclusive write transaction. + /// + /// With `BEGIN EXCLUSIVE`, a write lock is acquired immediately. In rollback journal mode, it + /// also prevents other connections from reading the database while the transaction is active. + /// In WAL mode, it behaves the same as `.immediate`. + case exclusive + + /// A textual representation of the transaction type. + public var description: String { + rawValue + } + + /// Returns the SQLite keyword that represents the transaction type. + /// + /// The value is always uppercased to match the keywords used by SQLite statements. + public var rawValue: String { + switch self { + case .deferred: "DEFERRED" + case .immediate: "IMMEDIATE" + case .exclusive: "EXCLUSIVE" + } + } + + /// Creates a transaction type from an SQLite keyword. + /// + /// The initializer accepts any ASCII case variant of the keyword (`"deferred"`, `"Deferred"`, + /// etc.). Returns `nil` if the string does not correspond to a supported transaction type. + public init?(rawValue: String) { + switch rawValue.uppercased() { + case "DEFERRED": self = .deferred + case "IMMEDIATE": self = .immediate + case "EXCLUSIVE": self = .exclusive + default: return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/BinaryFloatingPoint.swift b/Sources/DataLiteCore/Extensions/BinaryFloatingPoint.swift new file mode 100644 index 0000000..9f38bb9 --- /dev/null +++ b/Sources/DataLiteCore/Extensions/BinaryFloatingPoint.swift @@ -0,0 +1,36 @@ +import Foundation + +public extension SQLiteBindable where Self: BinaryFloatingPoint { + /// Converts a floating-point value to its SQLite representation. + /// + /// Floating-point numbers are stored in SQLite as `REAL` values. This property wraps the + /// current value into an ``SQLiteValue/real(_:)`` case, suitable for parameter binding. + /// + /// - Returns: An ``SQLiteValue`` of type `.real` containing the numeric value. + var sqliteValue: SQLiteValue { + .real(.init(self)) + } +} + +public extension SQLiteRepresentable where Self: BinaryFloatingPoint { + /// Creates a floating-point value from an SQLite representation. + /// + /// This initializer supports both ``SQLiteValue/real(_:)`` and ``SQLiteValue/int(_:)`` cases, + /// converting the stored number to the corresponding floating-point type. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A new instance if the conversion succeeds, or `nil` if the value is incompatible. + init?(_ value: SQLiteValue) { + switch value { + case .int(let value): + self.init(Double(value)) + case .real(let value): + self.init(value) + default: + return nil + } + } +} + +extension Float: SQLiteRepresentable {} +extension Double: SQLiteRepresentable {} diff --git a/Sources/DataLiteCore/Extensions/BinaryInteger.swift b/Sources/DataLiteCore/Extensions/BinaryInteger.swift new file mode 100644 index 0000000..1bccbcf --- /dev/null +++ b/Sources/DataLiteCore/Extensions/BinaryInteger.swift @@ -0,0 +1,44 @@ +import Foundation + +public extension SQLiteBindable where Self: BinaryInteger { + /// Converts an integer value to its SQLite representation. + /// + /// Integer values are stored in SQLite as `INTEGER` values. This property wraps the current + /// value into an ``SQLiteValue/int(_:)`` case, suitable for use in parameter binding. + /// + /// - Returns: An ``SQLiteValue`` of type `.int` containing the integer value. + var sqliteValue: SQLiteValue { + .int(Int64(self)) + } +} + +public extension SQLiteRepresentable where Self: BinaryInteger { + /// Creates an integer value from an SQLite representation. + /// + /// This initializer supports the ``SQLiteValue/int(_:)`` case and uses `init(exactly:)` to + /// ensure that the value fits within the bounds of the integer type. If the value cannot be + /// exactly represented, the initializer returns `nil`. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A new instance if the conversion succeeds, or `nil` otherwise. + init?(_ value: SQLiteValue) { + switch value { + case .int(let value): + self.init(exactly: value) + default: + return nil + } + } +} + +extension Int: SQLiteRepresentable {} +extension Int8: SQLiteRepresentable {} +extension Int16: SQLiteRepresentable {} +extension Int32: SQLiteRepresentable {} +extension Int64: SQLiteRepresentable {} + +extension UInt: SQLiteRepresentable {} +extension UInt8: SQLiteRepresentable {} +extension UInt16: SQLiteRepresentable {} +extension UInt32: SQLiteRepresentable {} +extension UInt64: SQLiteRepresentable {} diff --git a/Sources/DataLiteCore/Extensions/Bool.swift b/Sources/DataLiteCore/Extensions/Bool.swift new file mode 100644 index 0000000..b60cf61 --- /dev/null +++ b/Sources/DataLiteCore/Extensions/Bool.swift @@ -0,0 +1,31 @@ +import Foundation + +extension Bool: SQLiteRepresentable { + /// Converts a Boolean value to its SQLite representation. + /// + /// Boolean values are stored in SQLite as integers (`INTEGER` type). The value `true` is + /// represented as `1`, and `false` as `0`. + /// + /// - Returns: An ``SQLiteValue`` of type `.int`, containing `1` for `true` + /// and `0` for `false`. + public var sqliteValue: SQLiteValue { + .int(self ? 1 : 0) + } + + /// Creates a Boolean value from an SQLite representation. + /// + /// This initializer supports the ``SQLiteValue/int(_:)`` case and converts the integer value to + /// a Boolean. `1` is interpreted as `true`, `0` as `false`. If the integer is not `0` or `1`, + /// the initializer returns `nil`. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A Boolean value if the conversion succeeds, or `nil` otherwise. + public init?(_ value: SQLiteValue) { + switch value { + case .int(let value) where value == 0 || value == 1: + self = value == 1 + default: + return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/Data.swift b/Sources/DataLiteCore/Extensions/Data.swift new file mode 100644 index 0000000..8429aef --- /dev/null +++ b/Sources/DataLiteCore/Extensions/Data.swift @@ -0,0 +1,29 @@ +import Foundation + +extension Data: SQLiteRepresentable { + /// Converts a `Data` value to its SQLite representation. + /// + /// Binary data is stored in SQLite as a BLOB (`BLOB` type). This property wraps the current + /// value into an ``SQLiteValue/blob(_:)`` case, suitable for parameter binding. + /// + /// - Returns: An ``SQLiteValue`` of type `.blob` containing the binary data. + public var sqliteValue: SQLiteValue { + .blob(self) + } + + /// Creates a `Data` value from an SQLite representation. + /// + /// This initializer supports the ``SQLiteValue/blob(_:)`` case and converts the binary content + /// to a `Data` instance. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A `Data` instance if the conversion succeeds, or `nil` otherwise. + public init?(_ value: SQLiteValue) { + switch value { + case .blob(let data): + self = data + default: + return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/Date.swift b/Sources/DataLiteCore/Extensions/Date.swift new file mode 100644 index 0000000..469e05c --- /dev/null +++ b/Sources/DataLiteCore/Extensions/Date.swift @@ -0,0 +1,40 @@ +import Foundation + +extension Date: SQLiteRepresentable { + /// Converts a `Date` value to its SQLite representation. + /// + /// Dates are stored in SQLite as text using the ISO 8601 format. This property converts the + /// current date into an ISO 8601 string and wraps it in an ``SQLiteValue/text(_:)`` case, + /// suitable for parameter binding. + /// + /// - Returns: An ``SQLiteValue`` of type `.text`, containing the ISO 8601 string. + public var sqliteValue: SQLiteValue { + let formatter = ISO8601DateFormatter() + let dateString = formatter.string(from: self) + return .text(dateString) + } + + /// Creates a `Date` value from an SQLite representation. + /// + /// This initializer supports the following ``SQLiteValue`` cases: + /// - ``SQLiteValue/text(_:)`` — parses an ISO 8601 date string. + /// - ``SQLiteValue/int(_:)`` or ``SQLiteValue/real(_:)`` — interprets the number as a time + /// interval since 1970 (UNIX timestamp). + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A `Date` instance if the conversion succeeds, or `nil` otherwise. + public init?(_ value: SQLiteValue) { + switch value { + case .int(let value): + self.init(timeIntervalSince1970: TimeInterval(value)) + case .real(let value): + self.init(timeIntervalSince1970: value) + case .text(let value): + let formatter = ISO8601DateFormatter() + guard let date = formatter.date(from: value) else { return nil } + self = date + default: + return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/RawRepresentable.swift b/Sources/DataLiteCore/Extensions/RawRepresentable.swift new file mode 100644 index 0000000..f91af97 --- /dev/null +++ b/Sources/DataLiteCore/Extensions/RawRepresentable.swift @@ -0,0 +1,31 @@ +import Foundation + +public extension SQLiteBindable where Self: RawRepresentable, RawValue: SQLiteBindable { + /// Converts a `RawRepresentable` value to its SQLite representation. + /// + /// The `rawValue` of the conforming type must itself conform to ``SQLiteBindable``. This + /// property delegates the conversion to the underlying ``rawValue``. + /// + /// - Returns: The ``SQLiteValue`` representation of the underlying ``rawValue``. + var sqliteValue: SQLiteValue { + rawValue.sqliteValue + } +} + +public extension SQLiteRepresentable where Self: RawRepresentable, RawValue: SQLiteRepresentable { + /// Creates a `RawRepresentable` value from an SQLite representation. + /// + /// This initializer first attempts to create the underlying ``RawValue`` from the provided + /// ``SQLiteValue``. If successful, it uses that raw value to initialize the `RawRepresentable` + /// type. If the conversion fails, the initializer returns `nil`. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A new instance if the conversion succeeds, or `nil` otherwise. + init?(_ value: SQLiteValue) { + if let value = RawValue(value) { + self.init(rawValue: value) + } else { + return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/String.swift b/Sources/DataLiteCore/Extensions/String.swift new file mode 100644 index 0000000..1d07f14 --- /dev/null +++ b/Sources/DataLiteCore/Extensions/String.swift @@ -0,0 +1,29 @@ +import Foundation + +extension String: SQLiteRepresentable { + /// Converts a `String` value to its SQLite representation. + /// + /// Strings are stored in SQLite as text (`TEXT` type). This property wraps the current value + /// into an ``SQLiteValue/text(_:)`` case, suitable for parameter binding. + /// + /// - Returns: An ``SQLiteValue`` of type `.text` containing the string value. + public var sqliteValue: SQLiteValue { + .text(self) + } + + /// Creates a `String` value from an SQLite representation. + /// + /// This initializer supports the ``SQLiteValue/text(_:)`` case and converts the text content + /// to a `String` instance. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A `String` instance if the conversion succeeds, or `nil` otherwise. + public init?(_ value: SQLiteValue) { + switch value { + case .text(let value): + self = value + default: + return nil + } + } +} diff --git a/Sources/DataLiteCore/Extensions/UUID.swift b/Sources/DataLiteCore/Extensions/UUID.swift new file mode 100644 index 0000000..a316bc8 --- /dev/null +++ b/Sources/DataLiteCore/Extensions/UUID.swift @@ -0,0 +1,30 @@ +import Foundation + +extension UUID: SQLiteRepresentable { + /// Converts a `UUID` value to its SQLite representation. + /// + /// UUIDs are stored in SQLite as text (`TEXT` type) using their canonical string form + /// (e.g. `"550E8400-E29B-41D4-A716-446655440000"`). This property wraps the current value into + /// an ``SQLiteValue/text(_:)`` case. + /// + /// - Returns: An ``SQLiteValue`` of type `.text` containing the UUID string. + public var sqliteValue: SQLiteValue { + .text(self.uuidString) + } + + /// Creates a `UUID` value from an SQLite representation. + /// + /// This initializer supports the ``SQLiteValue/text(_:)`` case and attempts to parse the stored + /// text as a valid UUID string. + /// + /// - Parameter value: The SQLite value to convert from. + /// - Returns: A `UUID` instance if the string is valid, or `nil` otherwise. + public init?(_ value: SQLiteValue) { + switch value { + case .text(let value): + self.init(uuidString: value) + default: + return nil + } + } +} diff --git a/Sources/DataLiteCore/Protocols/ArgumentsProtocol.swift b/Sources/DataLiteCore/Protocols/ArgumentsProtocol.swift new file mode 100644 index 0000000..3079ea4 --- /dev/null +++ b/Sources/DataLiteCore/Protocols/ArgumentsProtocol.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A protocol representing a collection of SQLite argument values. +/// +/// Conforming types provide indexed access to a sequence of ``SQLiteValue`` elements. This protocol +/// extends `Collection` to allow convenient typed subscripting using types conforming to +/// ``SQLiteRepresentable``. +public protocol ArgumentsProtocol: Collection where Element == SQLiteValue, Index == Int { + /// Returns the element at the specified index, converted to the specified type. + /// + /// This subscript retrieves the argument value at the given index and attempts to convert it to + /// a type conforming to ``SQLiteRepresentable``. If the conversion succeeds, the resulting + /// value of type `T` is returned. Otherwise, `nil` is returned. + /// + /// - Parameter index: The index of the value to retrieve and convert. + /// - Returns: A value of type `T` if conversion succeeds, or `nil` if it fails. + subscript(index: Index) -> T? { get } +} + +public extension ArgumentsProtocol { + subscript(index: Index) -> T? { + T.init(self[index]) + } +} diff --git a/Sources/DataLiteCore/Protocols/ConnectionDelegate.swift b/Sources/DataLiteCore/Protocols/ConnectionDelegate.swift new file mode 100644 index 0000000..af04f04 --- /dev/null +++ b/Sources/DataLiteCore/Protocols/ConnectionDelegate.swift @@ -0,0 +1,43 @@ +import Foundation + +/// A delegate that observes connection-level database events. +/// +/// Conforming types can monitor row-level updates and transaction lifecycle events. This protocol +/// is typically used for debugging, logging, or synchronizing application state with database +/// changes. +/// +/// - Important: Delegate methods are invoked synchronously on SQLite’s internal execution thread. +/// Implementations must be lightweight and non-blocking to avoid slowing down SQL operations. +/// +/// ## Topics +/// +/// ### Instance Methods +/// +/// - ``ConnectionDelegate/connection(_:didUpdate:)`` +/// - ``ConnectionDelegate/connectionWillCommit(_:)`` +/// - ``ConnectionDelegate/connectionDidRollback(_:)`` +public protocol ConnectionDelegate: AnyObject { + /// Called when a row is inserted, updated, or deleted. + /// + /// Enables reacting to data changes, for example to refresh caches or UI. + /// + /// - Parameters: + /// - connection: The connection where the update occurred. + /// - action: Describes the affected database, table, and row. + func connection(_ connection: ConnectionProtocol, didUpdate action: SQLiteAction) + + /// Called right before a transaction is committed. + /// + /// Throwing an error aborts the commit and causes a rollback. + /// + /// - Parameter connection: The connection about to commit. + /// - Throws: An error to cancel and roll back the transaction. + func connectionWillCommit(_ connection: ConnectionProtocol) throws + + /// Called after a transaction is rolled back. + /// + /// Use to perform cleanup or maintain consistency after a failure. + /// + /// - Parameter connection: The connection that rolled back. + func connectionDidRollback(_ connection: ConnectionProtocol) +} diff --git a/Sources/DataLiteCore/Protocols/ConnectionProtocol.swift b/Sources/DataLiteCore/Protocols/ConnectionProtocol.swift new file mode 100644 index 0000000..1982b6c --- /dev/null +++ b/Sources/DataLiteCore/Protocols/ConnectionProtocol.swift @@ -0,0 +1,437 @@ +import Foundation + +/// A protocol that defines an SQLite database connection. +/// +/// The `ConnectionProtocol` defines the essential API for managing a database connection, +/// including configuration, statement preparation, transactions, encryption, and delegation. +/// Conforming types are responsible for maintaining the connection’s lifecycle and settings. +/// +/// ## Topics +/// +/// ### Managing Connection State +/// +/// - ``isAutocommit`` +/// - ``isReadonly`` +/// +/// ### Accessing PRAGMA Values +/// +/// - ``busyTimeout`` +/// - ``applicationID`` +/// - ``foreignKeys`` +/// - ``journalMode`` +/// - ``synchronous`` +/// - ``userVersion`` +/// +/// ### Managing SQLite Lifecycle +/// +/// - ``initialize()`` +/// - ``shutdown()`` +/// +/// ### Handling Encryption +/// +/// - ``apply(_:name:)`` +/// - ``rekey(_:name:)`` +/// +/// ### Managing Delegates +/// +/// - ``add(delegate:)`` +/// - ``remove(delegate:)`` +/// - ``add(trace:)`` +/// - ``remove(trace:)`` +/// +/// ### Registering Custom SQL Functions +/// +/// - ``add(function:)`` +/// - ``remove(function:)`` +/// +/// ### Preparing SQL Statements +/// +/// - ``prepare(sql:)`` +/// - ``prepare(sql:options:)`` +/// +/// ### Executing SQL Commands +/// +/// - ``execute(sql:)`` +/// +/// ### Controlling PRAGMA Settings +/// +/// - ``get(pragma:)`` +/// - ``set(pragma:value:)`` +/// +/// ### Managing Transactions +/// +/// - ``beginTransaction(_:)`` +/// - ``commitTransaction()`` +/// - ``rollbackTransaction()`` +public protocol ConnectionProtocol: AnyObject { + // MARK: - Connection State + + /// The autocommit state of the connection. + /// + /// Autocommit is enabled by default and remains active when no explicit transaction is open. + /// Executing `BEGIN` disables autocommit, while `COMMIT` or `ROLLBACK` re-enables it. + /// + /// - Returns: `true` if autocommit mode is active; otherwise, `false`. + /// - SeeAlso: [Test For Auto-Commit Mode](https://sqlite.org/c3ref/get_autocommit.html) + var isAutocommit: Bool { get } + + /// The read-only state of the connection. + /// + /// Returns `true` if the main database allows only read operations, or `false` if it permits + /// both reading and writing. + /// + /// - Returns: `true` if the connection is read-only; otherwise, `false`. + /// - SeeAlso: [Determine if a database is read-only](https://sqlite.org/c3ref/db_readonly.html) + var isReadonly: Bool { get } + + // MARK: - PRAGMA Accessors + + /// The busy timeout of the connection, in milliseconds. + /// + /// Defines how long SQLite waits for a locked database to become available before returning + /// a `SQLITE_BUSY` error. A value of zero disables the timeout, causing operations to fail + /// immediately if the database is locked. + /// + /// - SeeAlso: [Set A Busy Timeout](https://sqlite.org/c3ref/busy_timeout.html) + var busyTimeout: Int32 { get set } + + /// The application identifier stored in the database header. + /// + /// Used to distinguish database files created by different applications or file formats. This + /// value is a 32-bit integer written to the database header and can be queried or modified + /// through the `PRAGMA application_id` command. + /// + /// - SeeAlso: [Application ID](https://sqlite.org/pragma.html#pragma_application_id) + var applicationID: Int32 { get set } + + /// The foreign key enforcement state of the connection. + /// + /// When enabled, SQLite enforces foreign key constraints on all tables. This behavior can be + /// controlled with `PRAGMA foreign_keys`. + /// + /// - SeeAlso: [Foreign Keys](https://sqlite.org/pragma.html#pragma_foreign_keys) + var foreignKeys: Bool { get set } + + /// The journal mode used by the database connection. + /// + /// Determines how SQLite maintains the rollback journal for transactions. + /// + /// - SeeAlso: [Journal Mode](https://sqlite.org/pragma.html#pragma_journal_mode) + var journalMode: JournalMode { get set } + + /// The synchronization mode for database writes. + /// + /// Controls how aggressively SQLite syncs data to disk for durability versus performance. + /// + /// - SeeAlso: [Synchronous](https://sqlite.org/pragma.html#pragma_synchronous) + var synchronous: Synchronous { get set } + + /// The user-defined schema version number. + /// + /// This value is stored in the database header and can be used by applications to track schema + /// migrations or format changes. + /// + /// - SeeAlso: [User Version](https://sqlite.org/pragma.html#pragma_user_version) + var userVersion: Int32 { get set } + + // MARK: - SQLite Lifecycle + + /// Initializes the SQLite library. + /// + /// Sets up the global state required by SQLite, including operating-system–specific + /// initialization. This function must be called before using any other SQLite API, + /// unless the library is initialized automatically. + /// + /// Only the first invocation during the process lifetime, or the first after + /// ``shutdown()``, performs real initialization. All subsequent calls are no-ops. + /// + /// - Note: Workstation applications normally do not need to call this function explicitly, + /// as it is invoked automatically by interfaces such as `sqlite3_open()`. It is mainly + /// intended for embedded systems and controlled initialization scenarios. + /// + /// - Throws: ``SQLiteError`` if initialization fails. + /// - SeeAlso: [Initialize The SQLite Library](https://sqlite.org/c3ref/initialize.html) + static func initialize() throws(SQLiteError) + + /// Shuts down the SQLite library. + /// + /// Releases all global resources allocated by SQLite and undoes the effects of a + /// successful call to ``initialize()``. This function should be called exactly once + /// for each effective initialization and only after all database connections are closed. + /// + /// Only the first invocation since the last call to ``initialize()`` performs + /// deinitialization. All other calls are harmless no-ops. + /// + /// - Note: Workstation applications normally do not need to call this function explicitly, + /// as cleanup happens automatically at process termination. It is mainly used in + /// embedded systems where precise resource control is required. + /// + /// - Important: This function is **not** threadsafe and must be called from a single thread. + /// - Throws: ``SQLiteError`` if the shutdown process fails. + /// - SeeAlso: [Initialize The SQLite Library](https://sqlite.org/c3ref/initialize.html) + static func shutdown() throws(SQLiteError) + + // MARK: - Encryption + + /// Applies an encryption key to a database connection. + /// + /// If the database is newly created, this call initializes encryption and makes it encrypted. + /// If the database already exists, this call decrypts its contents for access using the + /// provided key. An existing unencrypted database cannot be encrypted using this method. + /// + /// This function must be called immediately after the connection is opened and before invoking + /// any other operation on the same connection. + /// + /// - Parameters: + /// - key: The encryption key to apply. + /// - name: The database name, or `nil` for the main database. + /// - Throws: ``SQLiteError`` if the key is invalid or the decryption process fails. + /// - SeeAlso: [Setting The Key](https://www.zetetic.net/sqlcipher/sqlcipher-api/#key) + func apply(_ key: Connection.Key, name: String?) throws(SQLiteError) + + /// Changes the encryption key for an open database. + /// + /// Re-encrypts the database file with a new key while preserving its existing data. The + /// connection must already be open and unlocked with a valid key applied through + /// ``apply(_:name:)``. This operation replaces the current encryption key but does not modify + /// the database contents. + /// + /// This function can only be used with an encrypted database. It has no effect on unencrypted + /// databases. + /// + /// - Parameters: + /// - key: The new encryption key to apply. + /// - name: The database name, or `nil` for the main database. + /// - Throws: ``SQLiteError`` if rekeying fails or encryption is not supported. + /// - SeeAlso: [Changing The Key](https://www.zetetic.net/sqlcipher/sqlcipher-api/#Changing_Key) + func rekey(_ key: Connection.Key, name: String?) throws(SQLiteError) + + // MARK: - Delegation + + /// Adds a delegate to receive connection-level events. + /// + /// Registers an object conforming to ``ConnectionDelegate`` to receive notifications such as + /// update actions and transaction events. + /// + /// - Parameter delegate: The delegate to add. + func add(delegate: ConnectionDelegate) + + /// Removes a previously added delegate. + /// + /// Unregisters an object that was previously added with ``add(delegate:)`` so it no longer + /// receives update and transaction events. + /// + /// - Parameter delegate: The delegate to remove. + func remove(delegate: ConnectionDelegate) + + /// Adds a delegate to receive SQL trace callbacks. + /// + /// Registers an object conforming to ``ConnectionTraceDelegate`` to observe SQL statements as + /// they are executed by the connection. + /// + /// - Parameter delegate: The trace delegate to add. + func add(trace delegate: ConnectionTraceDelegate) + + /// Removes a previously added trace delegate. + /// + /// Unregisters an object that was previously added with ``add(trace:)`` so it no longer + /// receives SQL trace callbacks. + /// + /// - Parameter delegate: The trace delegate to remove. + func remove(trace delegate: ConnectionTraceDelegate) + + // MARK: - Custom SQL Functions + + /// Registers a custom SQLite function with the current connection. + /// + /// The specified function type must be a subclass of ``Function/Scalar`` or + /// ``Function/Aggregate``. Once registered, the function becomes available in SQL queries + /// executed through this connection. + /// + /// - Parameter function: The custom function type to register. + /// - Throws: ``SQLiteError`` if registration fails. + func add(function: Function.Type) throws(SQLiteError) + + /// Unregisters a previously registered custom SQLite function. + /// + /// The specified function type must match the one used during registration. After removal, + /// the function will no longer be available for use in SQL statements. + /// + /// - Parameter function: The custom function type to unregister. + /// - Throws: ``SQLiteError`` if the function could not be unregistered. + func remove(function: Function.Type) throws(SQLiteError) + + // MARK: - Statement Preparation + + /// Prepares an SQL statement for execution. + /// + /// Compiles the provided SQL query into a prepared statement associated with this connection. + /// Use the returned statement to bind parameters and execute queries safely and efficiently. + /// + /// - Parameter query: The SQL query to prepare. + /// - Returns: A compiled statement ready for execution. + /// - Throws: ``SQLiteError`` if the statement could not be prepared. + /// + /// - SeeAlso: [Compiling An SQL Statement](https://sqlite.org/c3ref/prepare.html) + func prepare(sql query: String) throws(SQLiteError) -> StatementProtocol + + /// Prepares an SQL statement with custom compilation options. + /// + /// Similar to ``prepare(sql:)`` but allows specifying additional compilation flags through + /// ``Statement/Options`` to control statement creation behavior. + /// + /// - Parameters: + /// - query: The SQL query to prepare. + /// - options: Additional compilation options. + /// - Returns: A compiled statement ready for execution. + /// - Throws: ``SQLiteError`` if the statement could not be prepared. + /// + /// - SeeAlso: [Compiling An SQL Statement](https://sqlite.org/c3ref/prepare.html) + func prepare( + sql query: String, options: Statement.Options + ) throws(SQLiteError) -> StatementProtocol + + // MARK: - SQL Execution + + /// Executes one or more SQL statements in a single step. + /// + /// The provided SQL string may contain one or more statements separated by semicolons. + /// Each statement is compiled and executed sequentially within the current connection. + /// This method is suitable for operations that do not produce result sets, such as + /// `CREATE TABLE`, `INSERT`, `UPDATE`, or `PRAGMA`. + /// + /// Execution stops at the first error, and the corresponding ``SQLiteError`` is thrown. + /// + /// - Parameter script: The SQL text containing one or more statements to execute. + /// - Throws: ``SQLiteError`` if any statement fails to execute. + /// + /// - SeeAlso: [One-Step Query Execution Interface](https://sqlite.org/c3ref/exec.html) + func execute(sql script: String) throws(SQLiteError) + + // MARK: - PRAGMA Control + + /// Reads the current value of a database PRAGMA. + /// + /// Retrieves the value of the specified PRAGMA and attempts to convert it to the provided + /// generic type `T`. This method is typically used for reading configuration or status values + /// such as `journal_mode`, `foreign_keys`, or `user_version`. + /// + /// If the PRAGMA query succeeds but the value cannot be converted to the requested type, + /// the method returns `nil` instead of throwing an error. + /// + /// - Parameter pragma: The PRAGMA to query. + /// - Returns: The current PRAGMA value, or `nil` if the result is empty or conversion fails. + /// - Throws: ``SQLiteError`` if the PRAGMA query itself fails. + /// + /// - SeeAlso: [PRAGMA Statements](https://sqlite.org/pragma.html) + func get(pragma: Pragma) throws(SQLiteError) -> T? + + /// Sets a database PRAGMA value. + /// + /// Assigns the specified value to the given PRAGMA. This can be used to change runtime + /// configuration parameters, such as `foreign_keys`, `journal_mode`, or `synchronous`. + /// + /// - Parameters: + /// - pragma: The PRAGMA to set. + /// - value: The value to assign to the PRAGMA. + /// - Throws: ``SQLiteError`` if the assignment fails. + /// + /// - SeeAlso: [PRAGMA Statements](https://sqlite.org/pragma.html) + func set(pragma: Pragma, value: T) throws(SQLiteError) + + // MARK: - Transactions + + /// Begins a new transaction of the specified type. + /// + /// Starts an explicit transaction using the given ``TransactionType``. If a transaction is + /// already active, this method throws an error. + /// + /// - Parameter type: The transaction type to begin. + /// - Throws: ``SQLiteError`` if the transaction could not be started. + /// + /// - SeeAlso: [Transaction](https://sqlite.org/lang_transaction.html) + func beginTransaction(_ type: TransactionType) throws(SQLiteError) + + /// Commits the current transaction. + /// + /// Makes all changes made during the transaction permanent. If no transaction is active, this + /// method has no effect. + /// + /// - Throws: ``SQLiteError`` if the commit operation fails. + /// + /// - SeeAlso: [Transaction](https://sqlite.org/lang_transaction.html) + func commitTransaction() throws(SQLiteError) + + /// Rolls back the current transaction. + /// + /// Reverts all changes made during the transaction. If no transaction is active, this method + /// has no effect. + /// + /// - Throws: ``SQLiteError`` if the rollback operation fails. + /// + /// - SeeAlso: [Transaction](https://sqlite.org/lang_transaction.html) + func rollbackTransaction() throws(SQLiteError) +} + +// MARK: - Default Implementation + +public extension ConnectionProtocol { + var busyTimeout: Int32 { + get { try! get(pragma: .busyTimeout) ?? 0 } + set { try! set(pragma: .busyTimeout, value: newValue) } + } + + var applicationID: Int32 { + get { try! get(pragma: .applicationID) ?? 0 } + set { try! set(pragma: .applicationID, value: newValue) } + } + + var foreignKeys: Bool { + get { try! get(pragma: .foreignKeys) ?? false } + set { try! set(pragma: .foreignKeys, value: newValue) } + } + + var journalMode: JournalMode { + get { try! get(pragma: .journalMode) ?? .off } + set { try! set(pragma: .journalMode, value: newValue) } + } + + var synchronous: Synchronous { + get { try! get(pragma: .synchronous) ?? .off } + set { try! set(pragma: .synchronous, value: newValue) } + } + + var userVersion: Int32 { + get { try! get(pragma: .userVersion) ?? 0 } + set { try! set(pragma: .userVersion, value: newValue) } + } + + func prepare(sql query: String) throws(SQLiteError) -> StatementProtocol { + try prepare(sql: query, options: []) + } + + func get(pragma: Pragma) throws(SQLiteError) -> T? { + let stmt = try prepare(sql: "PRAGMA \(pragma)") + switch try stmt.step() { + case true: return stmt.columnValue(at: 0) + case false: return nil + } + } + + func set(pragma: Pragma, value: T) throws(SQLiteError) { + let query = "PRAGMA \(pragma) = \(value.sqliteLiteral)" + try prepare(sql: query).step() + } + + func beginTransaction(_ type: TransactionType = .deferred) throws(SQLiteError) { + try prepare(sql: "BEGIN \(type) TRANSACTION", options: []).step() + } + + func commitTransaction() throws(SQLiteError) { + try prepare(sql: "COMMIT TRANSACTION", options: []).step() + } + + func rollbackTransaction() throws(SQLiteError) { + try prepare(sql: "ROLLBACK TRANSACTION", options: []).step() + } +} diff --git a/Sources/DataLiteCore/Protocols/ConnectionTraceDelegate.swift b/Sources/DataLiteCore/Protocols/ConnectionTraceDelegate.swift new file mode 100644 index 0000000..e800667 --- /dev/null +++ b/Sources/DataLiteCore/Protocols/ConnectionTraceDelegate.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A delegate that receives SQL statement trace callbacks. +/// +/// Conforming types can inspect SQL before and after parameter expansion for logging, diagnostics, +/// or profiling. Register a trace delegate with ``ConnectionProtocol/add(trace:)``. +/// +/// - Important: Callbacks execute synchronously on SQLite’s internal thread. Keep implementations +/// lightweight to avoid slowing down query execution. +public protocol ConnectionTraceDelegate: AnyObject { + /// Represents traced SQL text before and after parameter substitution. + typealias Trace = (unexpandedSQL: String, expandedSQL: String) + + /// Called before a SQL statement is executed. + /// + /// Use to trace or log executed statements for debugging or profiling. + /// + /// - Parameters: + /// - connection: The active database connection. + /// - sql: A tuple with the original and expanded SQL text. + func connection(_ connection: ConnectionProtocol, trace sql: Trace) +} diff --git a/Sources/DataLiteCore/Protocols/SQLiteBindable.swift b/Sources/DataLiteCore/Protocols/SQLiteBindable.swift new file mode 100644 index 0000000..1448779 --- /dev/null +++ b/Sources/DataLiteCore/Protocols/SQLiteBindable.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A protocol whose conforming types can be used in SQLite statements and queries. +/// +/// Conforming types provide a raw SQLite value for binding to prepared-statement parameters +/// and an SQL literal that can be inserted directly into SQL text. +/// +/// ```swift +/// struct Device: SQLiteBindable { +/// var model: String +/// +/// var sqliteValue: SQLiteValue { +/// return .text(model) +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Instance Properties +/// +/// - ``sqliteValue`` +/// - ``sqliteLiteral`` +public protocol SQLiteBindable { + /// The raw SQLite value representation. + /// + /// Supplies a value compatible with SQLite's internal representation. Used when binding + /// conforming types to parameters of a prepared SQLite statement. + var sqliteValue: SQLiteValue { get } + + /// The SQL literal representation. + /// + /// Provides a string that conforms to SQL syntax and is compatible with SQLite's rules + /// for literals. Defaults to ``SQLiteValue/sqliteLiteral``. + var sqliteLiteral: String { get } +} + +public extension SQLiteBindable { + var sqliteLiteral: String { + sqliteValue.sqliteLiteral + } +} diff --git a/Sources/DataLiteCore/Protocols/SQLiteRepresentable.swift b/Sources/DataLiteCore/Protocols/SQLiteRepresentable.swift new file mode 100644 index 0000000..caa861c --- /dev/null +++ b/Sources/DataLiteCore/Protocols/SQLiteRepresentable.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A protocol whose conforming types can be initialized from raw SQLite values. +/// +/// This protocol extends ``SQLiteBindable`` and adds an initializer for converting a raw SQLite +/// value into the corresponding type. +/// +/// ```swift +/// struct Device: SQLiteRepresentable { +/// var model: String +/// +/// var sqliteValue: SQLiteValue { +/// return .text(model) +/// } +/// +/// init?(_ value: SQLiteValue) { +/// switch value { +/// case .text(let value): +/// self.model = value +/// default: +/// return nil +/// } +/// } +/// } +/// ``` +public protocol SQLiteRepresentable: SQLiteBindable { + /// Initializes an instance from a raw SQLite value. + /// + /// The initializer should map the provided raw SQLite value to the corresponding type. + /// If the conversion is not possible (for example, the value has an incompatible type), + /// the initializer should return `nil`. + /// + /// - Parameter value: The raw SQLite value to convert. + init?(_ value: SQLiteValue) +} diff --git a/Sources/DataLiteCore/Protocols/StatementProtocol.swift b/Sources/DataLiteCore/Protocols/StatementProtocol.swift new file mode 100644 index 0000000..15e9c17 --- /dev/null +++ b/Sources/DataLiteCore/Protocols/StatementProtocol.swift @@ -0,0 +1,330 @@ +import Foundation + +/// A protocol that defines a prepared SQLite statement. +/// +/// Conforming types manage the statement's lifetime, including initialization and finalization. +/// The protocol exposes facilities for parameter discovery and binding, stepping, resetting, and +/// reading result columns. +/// +/// ## Topics +/// +/// ### Retrieving Statement SQL +/// +/// - ``sql`` +/// - ``expandedSQL`` +/// +/// ### Binding Parameters +/// +/// - ``parameterCount()`` +/// - ``parameterIndexBy(_:)`` +/// - ``parameterNameBy(_:)`` +/// - ``bind(_:at:)-(SQLiteValue,_)`` +/// - ``bind(_:by:)-(SQLiteValue,_)`` +/// - ``bind(_:at:)-(T?,_)`` +/// - ``bind(_:by:)-(T?,_)`` +/// - ``bind(_:)`` +/// - ``clearBindings()`` +/// +/// ### Statement Execution +/// +/// - ``step()`` +/// - ``reset()`` +/// - ``execute(_:)`` +/// +/// ### Result Set +/// +/// - ``columnCount()`` +/// - ``columnName(at:)`` +/// - ``columnValue(at:)->SQLiteValue`` +/// - ``columnValue(at:)->T?`` +/// - ``currentRow()`` +public protocol StatementProtocol { + // MARK: - Retrieving Statement SQL + + /// The original SQL text used to create this prepared statement. + /// + /// Returns the statement text as it was supplied when the statement was prepared. Useful for + /// diagnostics or query introspection. + var sql: String? { get } + + /// The SQL text with all parameter values expanded into their literal forms. + /// + /// Shows the actual SQL string that would be executed after parameter binding. Useful for + /// debugging and logging queries. + var expandedSQL: String? { get } + + // MARK: - Binding Parameters + + /// Returns the number of parameters in the prepared SQLite statement. + /// + /// This value corresponds to the highest parameter index in the compiled SQL statement. + /// Parameters may be specified using anonymous placeholders (`?`), numbered placeholders + /// (`?NNN`), or named placeholders (`:name`, `@name`, `$name`). + /// + /// For statements using only `?` or named parameters, this value equals the number of parameters. + /// However, if numbered placeholders are used, the sequence may contain gaps — for example, + /// a statement containing `?2` and `?5` will report a parameter count of `5`. + /// + /// - Returns: The index of the largest (rightmost) parameter in the prepared statement. + /// + /// - SeeAlso: [Number Of SQL Parameters](https://sqlite.org/c3ref/bind_parameter_count.html) + func parameterCount() -> Int32 + + /// Returns the index of a parameter identified by its name. + /// + /// The `name` must exactly match the placeholder used in the SQL statement, including its + /// prefix character (`:`, `@`, or `$`). For example, if the SQL includes `WHERE id = :id`, + /// you must call `parameterIndexBy(":id")`. + /// + /// If no parameter with the specified `name` exists in the prepared statement, this function + /// returns `0`. + /// + /// - Parameter name: The parameter name as written in the SQL statement, including its prefix. + /// - Returns: The 1-based parameter index corresponding to `name`, or `0` if not found. + /// + /// - SeeAlso: [Index Of A Parameter With A Given Name](https://sqlite.org/c3ref/bind_parameter_index.html) + func parameterIndexBy(_ name: String) -> Int32 + + /// Returns the name of the parameter at the specified index. + /// + /// The returned string matches the placeholder as written in the SQL statement, including its + /// prefix (`:`, `@`, or `$`). For positional (unnamed) parameters, or if the `index` is out of + /// range, this function returns `nil`. + /// + /// - Parameter index: A 1-based parameter index. + /// - Returns: The parameter name as written in the SQL statement, or `nil` if unavailable. + /// + /// - SeeAlso: [Name Of A Host Parameter](https://sqlite.org/c3ref/bind_parameter_name.html) + func parameterNameBy(_ index: Int32) -> String? + + /// Binds a raw SQLite value to a parameter at the specified index. + /// + /// Assigns the given `SQLiteValue` to the parameter at the provided 1-based index within the + /// prepared statement. If the index is out of range, or if the statement is invalid or + /// finalized, this function throws an error. + /// + /// - Parameters: + /// - value: The `SQLiteValue` to bind to the parameter. + /// - index: The 1-based index of the parameter to bind. + /// - Throws: ``SQLiteError`` if the value cannot be bound (e.g., index out of range). + /// + /// - SeeAlso: [Binding Values To Prepared Statements]( + /// https://sqlite.org/c3ref/bind_blob.html) + func bind(_ value: SQLiteValue, at index: Int32) throws(SQLiteError) + + /// Binds a raw SQLite value to a parameter by its name. + /// + /// Resolves `name` to an index and binds `value` to that parameter. The `name` must include + /// its prefix (e.g., `:AAA`, `@AAA`, `$AAA`). Binding a value to a parameter that does not + /// exist results in an error. + /// + /// - Parameters: + /// - value: The ``SQLiteValue`` to bind. + /// - name: The parameter name exactly as written in SQL, including its prefix. + /// - Throws: ``SQLiteError`` if binding fails. + /// + /// - SeeAlso: [Binding Values To Prepared Statements]( + /// https://sqlite.org/c3ref/bind_blob.html) + func bind(_ value: SQLiteValue, by name: String) throws(SQLiteError) + + /// Binds a typed value conforming to `SQLiteBindable` by index. + /// + /// Converts `value` to its raw SQLite representation and binds it at `index`. If `value` is + /// `nil`, binds `NULL`. + /// + /// - Parameters: + /// - value: The value to bind. If `nil`, `NULL` is bound. + /// - index: The 1-based parameter index. + /// - Throws: ``SQLiteError`` if binding fails. + /// + /// - SeeAlso: [Binding Values To Prepared Statements]( + /// https://sqlite.org/c3ref/bind_blob.html) + func bind(_ value: T?, at index: Int32) throws(SQLiteError) + + /// Binds a typed value conforming to `SQLiteBindable` by name. + /// + /// Resolves `name` to a parameter index and binds the raw SQLite representation of `value`. + /// If `value` is `nil`, binds `NULL`. The `name` must include its prefix (e.g., `:AAA`, + /// `@AAA`, `$AAA`). Binding to a non-existent parameter results in an error. + /// + /// - Parameters: + /// - value: The value to bind. If `nil`, `NULL` is bound. + /// - name: The parameter name exactly as written in SQL, including its prefix. + /// - Throws: ``SQLiteError`` if binding fails. + /// + /// - SeeAlso: [Binding Values To Prepared Statements]( + /// https://sqlite.org/c3ref/bind_blob.html) + func bind(_ value: T?, by name: String) throws(SQLiteError) + + /// Binds the contents of a row to named statement parameters by column name. + /// + /// For each `(column, value)` pair in `row`, treats `column` as a named parameter `:column` + /// and binds `value` to that parameter. Parameter names in the SQL must match the row's + /// column names (including the leading colon). Binding to a non-existent parameter results + /// in an error. + /// + /// - Parameter row: The row whose column values are to be bound. + /// - Throws: ``SQLiteError`` if any value cannot be bound. + /// + /// - SeeAlso: [Binding Values To Prepared Statements]( + /// https://sqlite.org/c3ref/bind_blob.html) + func bind(_ row: SQLiteRow) throws(SQLiteError) + + /// Clears all parameter bindings of the prepared statement. + /// + /// After calling this function, all parameters are set to `NULL`. Call this when reusing the + /// statement with a different set of parameter values. + /// + /// - Throws: ``SQLiteError`` if clearing bindings fails. + /// + /// - SeeAlso: [Reset All Bindings](https://sqlite.org/c3ref/clear_bindings.html) + func clearBindings() throws(SQLiteError) + + // MARK: - Statement Execution + + /// Evaluates the prepared statement and advances to the next result row. + /// + /// Call repeatedly to iterate over all rows. It returns `true` while a new row is available. + /// After the final row it returns `false`. Statements that produce no rows return `false` + /// immediately. Reset the statement and clear bindings before re-executing. + /// + /// - Returns: `true` if a new row is available, or `false` when no more rows remain. + /// - Throws: ``SQLiteError`` if evaluation fails. + /// + /// - SeeAlso: [Evaluate An SQL Statement](https://sqlite.org/c3ref/step.html) + @discardableResult + func step() throws(SQLiteError) -> Bool + + /// Resets the prepared SQLite statement to its initial state, ready for re-execution. + /// + /// Undoes the effects of previous calls to ``step()``. After reset, the statement may be + /// executed again with the same or new inputs. This does not clear parameter bindings. + /// Call ``clearBindings()`` to set all parameters to `NULL` if needed. + /// + /// - Throws: ``SQLiteError`` if the statement cannot be reset. + /// + /// - SeeAlso: [Reset A Prepared Statement](https://sqlite.org/c3ref/reset.html) + func reset() throws(SQLiteError) + + /// Executes the statement once per provided parameter row. + /// + /// For each row, binds values, steps until completion (discarding any result rows), clears + /// bindings, and resets the statement. Use this for efficient batch executions (e.g., inserts + /// or updates) with different parameters per run. + /// + /// - Parameter rows: Parameter rows to bind for each execution. + /// - Throws: ``SQLiteError`` if binding, stepping, clearing, or resetting fails. + func execute(_ rows: [SQLiteRow]) throws(SQLiteError) + + // MARK: - Result Set + + /// Returns the number of columns in the current result set. + /// + /// If this value is `0`, the prepared statement does not produce rows. This is typically + /// the case for statements that do not return data. + /// + /// - Returns: The number of columns in the result set, or `0` if there are no result columns. + /// + /// - SeeAlso: [Number Of Columns In A Result Set]( + /// https://sqlite.org/c3ref/column_count.html) + func columnCount() -> Int32 + + /// Returns the name of the column at the specified index in the result set. + /// + /// The column name appears as defined in the SQL statement. If the index is out of bounds, this + /// function returns `nil`. + /// + /// - Parameter index: The 0-based index of the column for which to retrieve the name. + /// - Returns: The name of the column at the given index, or `nil` if the index is invalid. + /// + /// - SeeAlso: [Column Names In A Result Set](https://sqlite.org/c3ref/column_name.html) + func columnName(at index: Int32) -> String? + + /// Returns the raw SQLite value at the given result column index. + /// + /// Retrieves the value for the specified column in the current result row of the prepared + /// statement, represented as a ``SQLiteValue``. If the index is out of range, returns + /// ``SQLiteValue/null``. + /// + /// - Parameter index: The 0-based index of the result column to access. + /// - Returns: The raw ``SQLiteValue`` at the specified column. + /// + /// - SeeAlso: [Result Values From A Query](https://sqlite.org/c3ref/column_blob.html) + func columnValue(at index: Int32) -> SQLiteValue + + /// Returns the value of the result column at `index`, converted to `T`. + /// + /// Attempts to initialize `T` from the raw ``SQLiteValue`` at `index` using + /// ``SQLiteRepresentable``. Returns `nil` if the conversion is not possible. + /// + /// - Parameter index: The 0-based result column index. + /// - Returns: A value of type `T` if conversion succeeds, otherwise `nil`. + /// + /// - SeeAlso: [Result Values From A Query](https://sqlite.org/c3ref/column_blob.html) + func columnValue(at index: Int32) -> T? + + /// Returns the current result row. + /// + /// Builds a row by iterating over all result columns at the current cursor position, reading + /// each column's name and value, and inserting them into the row. + /// + /// - Returns: A `SQLiteRow` mapping column names to values, or `nil` if there are no columns. + /// + /// - SeeAlso: [Result Values From A Query](https://sqlite.org/c3ref/column_blob.html) + func currentRow() -> SQLiteRow? +} + +// MARK: - Default Implementation + +public extension StatementProtocol { + func bind(_ value: SQLiteValue, by name: String) throws(SQLiteError) { + try bind(value, at: parameterIndexBy(name)) + } + + func bind(_ value: T?, at index: Int32) throws(SQLiteError) { + try bind(value?.sqliteValue ?? .null, at: index) + } + + func bind(_ value: T?, by name: String) throws(SQLiteError) { + try bind(value?.sqliteValue ?? .null, at: parameterIndexBy(name)) + } + + func bind(_ row: SQLiteRow) throws(SQLiteError) { + for (column, value) in row { + let index = parameterIndexBy(":\(column)") + try bind(value, at: index) + } + } + + func execute(_ rows: [SQLiteRow]) throws(SQLiteError) { + for row in rows { + try bind(row) + var hasStep: Bool + repeat { + hasStep = try step() + } while hasStep + try clearBindings() + try reset() + } + } + + func columnValue(at index: Int32) -> T? { + T(columnValue(at: index)) + } + + func currentRow() -> SQLiteRow? { + let columnCount = columnCount() + guard columnCount > 0 else { return nil } + + var row = SQLiteRow() + row.reserveCapacity(columnCount) + + for index in 0.. + + /// The column names in the order they appear in the result set. + /// + /// The order of column names corresponds to the sequence defined in the executed SQL statement. + /// This order is preserved exactly as provided by SQLite, ensuring deterministic column + /// indexing across rows. + public var columns: [Column] { + elements.keys.elements + } + + /// The named parameter tokens corresponding to each column, in result order. + /// + /// Each element is formed by prefixing the column name with a colon (`:`), matching the syntax + /// of SQLite named parameters (e.g., `:username`, `:id`). The order of tokens matches the order + /// of columns in the result set. + public var namedParameters: [String] { + elements.keys.map { ":\($0)" } + } + + // MARK: - Inits + + /// Creates an empty row with no columns. + public init() { + elements = [:] + } + + // MARK: - Subscripts + + /// Accesses the value associated with the specified column. + /// + /// Use this subscript to read or modify the value of a particular column by name. If the column + /// does not exist, the getter returns `nil` and assigning a value to a new column name adds it + /// to the row. + /// + /// - Parameter column: The name of the column. + /// - Returns: The value for the specified column, or `nil` if the column is not present. + /// - Complexity: Average O(1) lookup and amortized O(1) mutation. + public subscript(column: Column) -> Value? { + get { elements[column] } + set { elements[column] = newValue } + } + + // MARK: - Methods + + /// Checks whether the row contains a column with the specified name. + /// + /// Use this method to check if a column exists without retrieving its value or iterating + /// through all columns. + /// + /// - Parameter column: The name of the column to look for. + /// - Returns: `true` if the column exists, otherwise `false`. + /// - Complexity: Average O(1). + public func contains(_ column: Column) -> Bool { + elements.keys.contains(column) + } + + /// Reserves enough storage to hold the specified number of columns. + /// + /// Calling this method can minimize reallocations when adding multiple columns to the row. + /// + /// - Parameter minimumCapacity: The requested number of column-value pairs to store. + /// - Complexity: O(max(count, minimumCapacity)) + public mutating func reserveCapacity(_ minimumCapacity: Int) { + elements.reserveCapacity(minimumCapacity) + } + + /// Reserves enough storage to hold the specified number of columns. + /// + /// This overload provides a convenient interface for values originating from SQLite APIs, which + /// commonly use 32-bit integer sizes. + /// + /// - Parameter minimumCapacity: The requested number of column-value pairs to store. + /// - Complexity: O(max(count, minimumCapacity)) + public mutating func reserveCapacity(_ minimumCapacity: Int32) { + elements.reserveCapacity(Int(minimumCapacity)) + } +} + +// MARK: - CustomStringConvertible + +extension SQLiteRow: CustomStringConvertible { + /// A textual representation of the row as an ordered dictionary of column-value pairs. + public var description: String { + elements.description + } +} + +// MARK: - Collection + +extension SQLiteRow: Collection { + /// The element type of the row collection. + public typealias Element = (column: Column, value: Value) + + /// The index type used to access elements in the row. + public typealias Index = OrderedDictionary.Index + + /// The position of the first element in the row. + /// + /// If the row is empty, `startIndex` equals `endIndex`. Use this property as the starting + /// position when iterating over columns. + /// + /// - Complexity: O(1) + public var startIndex: Index { + elements.elements.startIndex + } + + /// The position one past the last valid element in the row. + /// + /// Use this property to detect the end of iteration when traversing columns. + /// + /// - Complexity: O(1) + public var endIndex: Index { + elements.elements.endIndex + } + + /// A Boolean value that indicates whether the row contains no columns. + /// + /// - Complexity: O(1) + public var isEmpty: Bool { + elements.isEmpty + } + + /// The number of column-value pairs in the row. + /// + /// - Complexity: O(1) + public var count: Int { + elements.count + } + + /// Accesses the element at the specified position in the row. + /// + /// - Parameter index: A valid index of the row. + /// - Returns: The (column, value) pair at the specified position. + /// - Complexity: O(1) + public subscript(index: Index) -> Element { + let element = elements.elements[index] + return (element.key, element.value) + } + + /// Returns the position immediately after the specified index. + /// + /// - Parameter i: A valid index of the row. + /// - Returns: The index immediately after `i`. + /// - Complexity: O(1) + public func index(after i: Index) -> Index { + elements.elements.index(after: i) + } +} + +// MARK: - ExpressibleByDictionaryLiteral + +extension SQLiteRow: ExpressibleByDictionaryLiteral { + /// Creates a `SQLiteRow` from a sequence of (column, value) pairs. + /// + /// - Parameter elements: The column-value pairs to include in the row. + /// - Note: Preserves the argument order and requires unique column names. + /// - Complexity: O(n), where n is the number of pairs. + public init(dictionaryLiteral elements: (Column, Value)...) { + self.elements = .init(uniqueKeysWithValues: elements) + } +} + +// MARK: - Equatable + +extension SQLiteRow: Equatable {} + +// MARK: - Hashable + +extension SQLiteRow: Hashable {} + +// MARK: - Sendable + +extension SQLiteRow: Sendable {} diff --git a/Tests/DataLiteCoreTests/Classes/Connection+KeyTests.swift b/Tests/DataLiteCoreTests/Classes/Connection+KeyTests.swift new file mode 100644 index 0000000..c2b2641 --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Connection+KeyTests.swift @@ -0,0 +1,25 @@ +import Testing +import Foundation +import DataLiteCore + +struct ConnectionKeyTests { + @Test func passphrase() { + let key = Connection.Key.passphrase("secret123") + #expect(key.keyValue == "secret123") + #expect(key.length == 9) + } + + @Test func rawKey() { + let keyData = Data([0x01, 0xAB, 0xCD, 0xEF]) + let key = Connection.Key.rawKey(keyData) + #expect(key.keyValue == "X'01ABCDEF'") + #expect(key.length == 11) + } + + @Test func rawKeyLengthConsistency() { + let rawBytes = Data(repeating: 0x00, count: 32) + let key = Connection.Key.rawKey(rawBytes) + let hexPart = key.keyValue.dropFirst(2).dropLast() + #expect(hexPart.count == 64) + } +} diff --git a/Tests/DataLiteCoreTests/Classes/Connection+LocationTests.swift b/Tests/DataLiteCoreTests/Classes/Connection+LocationTests.swift new file mode 100644 index 0000000..0e97a82 --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Connection+LocationTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import DataLiteCore + +struct ConnectionLocationTests { + @Test func fileLocationPath() { + let filePath = "/path/to/database.db" + let location = Connection.Location.file(path: filePath) + #expect(location.path == filePath) + } + + @Test func inMemoryLocationPath() { + let inMemoryLocation = Connection.Location.inMemory + #expect(inMemoryLocation.path == ":memory:") + } + + @Test func temporaryLocationPath() { + let temporaryLocation = Connection.Location.temporary + #expect(temporaryLocation.path == "") + } +} diff --git a/Tests/DataLiteCoreTests/Classes/Connection+OptionsTests.swift b/Tests/DataLiteCoreTests/Classes/Connection+OptionsTests.swift new file mode 100644 index 0000000..c62cecc --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Connection+OptionsTests.swift @@ -0,0 +1,69 @@ +import Testing +import DataLiteC +import DataLiteCore + +struct ConnectionOptionsTests { + @Test func readOnlyOption() { + let options: Connection.Options = [.readonly] + #expect(options.contains(.readonly)) + } + + @Test func readWriteOption() { + let options: Connection.Options = [.readwrite] + #expect(options.contains(.readwrite)) + } + + @Test func createOption() { + let options: Connection.Options = [.create] + #expect(options.contains(.create)) + } + + @Test func multipleOptions() { + let options: Connection.Options = [.readwrite, .create, .memory] + #expect(options.contains(.readwrite)) + #expect(options.contains(.create)) + #expect(options.contains(.memory)) + } + + @Test func noFollowOption() { + let options: Connection.Options = [.nofollow] + #expect(options.contains(.nofollow)) + } + + @Test func allOptions() { + let options: Connection.Options = [ + .readonly, .readwrite, .create, .uri, .memory, + .nomutex, .fullmutex, .sharedcache, + .privatecache, .exrescode, .nofollow + ] + + #expect(options.contains(.readonly)) + #expect(options.contains(.readwrite)) + #expect(options.contains(.create)) + #expect(options.contains(.uri)) + #expect(options.contains(.memory)) + #expect(options.contains(.nomutex)) + #expect(options.contains(.fullmutex)) + #expect(options.contains(.sharedcache)) + #expect(options.contains(.privatecache)) + #expect(options.contains(.exrescode)) + #expect(options.contains(.nofollow)) + } + + @Test func optionsRawValue() { + let options: Connection.Options = [.readwrite, .create] + let expectedRawValue = Int32(SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE) + #expect(options.rawValue == expectedRawValue) + + #expect(Connection.Options.readonly.rawValue == SQLITE_OPEN_READONLY) + #expect(Connection.Options.readwrite.rawValue == SQLITE_OPEN_READWRITE) + #expect(Connection.Options.create.rawValue == SQLITE_OPEN_CREATE) + #expect(Connection.Options.memory.rawValue == SQLITE_OPEN_MEMORY) + #expect(Connection.Options.nomutex.rawValue == SQLITE_OPEN_NOMUTEX) + #expect(Connection.Options.fullmutex.rawValue == SQLITE_OPEN_FULLMUTEX) + #expect(Connection.Options.sharedcache.rawValue == SQLITE_OPEN_SHAREDCACHE) + #expect(Connection.Options.privatecache.rawValue == SQLITE_OPEN_PRIVATECACHE) + #expect(Connection.Options.exrescode.rawValue == SQLITE_OPEN_EXRESCODE) + #expect(Connection.Options.nofollow.rawValue == SQLITE_OPEN_NOFOLLOW) + } +} diff --git a/Tests/DataLiteCoreTests/Classes/ConnectionTests.swift b/Tests/DataLiteCoreTests/Classes/ConnectionTests.swift new file mode 100644 index 0000000..bd78b8f --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/ConnectionTests.swift @@ -0,0 +1,405 @@ +import Testing +import Foundation +import DataLiteC + +@testable import DataLiteCore + +struct ConnectionTests { + @Test(arguments: [ + Connection.Location.inMemory, + Connection.Location.temporary + ]) + func initLocation(_ location: Connection.Location) throws { + let _ = try Connection(location: location, options: [.create, .readwrite]) + } + + @Test func initPath() throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + let _ = try Connection(path: path, options: [.create, .readwrite]) + } + + @Test func initPathFail() { + #expect( + throws: SQLiteError( + code: SQLITE_CANTOPEN, + message: "unable to open database file" + ), + performing: { + try Connection( + path: "/invalid-path/", + options: [.create, .readwrite] + ) + } + ) + } + + @Test func isAutocommit() throws { + let connection = try Connection( + location: .inMemory, + options: [.create, .readwrite] + ) + #expect(connection.isAutocommit) + } + + @Test(arguments: [ + (Connection.Options.readwrite, false), + (Connection.Options.readonly, true) + ]) + func isReadonly( + _ options: Connection.Options, + _ expected: Bool + ) throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + + let _ = try Connection(path: path, options: [.create, .readwrite]) + let connection = try Connection(path: path, options: options) + + #expect(connection.isReadonly == expected) + } + + @Test func testBusyTimeout() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + connection.busyTimeout = 5000 + #expect(try connection.get(pragma: .busyTimeout) == 5000) + + try connection.set(pragma: .busyTimeout, value: 1000) + #expect(connection.busyTimeout == 1000) + } + + @Test func testApplicationID() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + + #expect(connection.applicationID == 0) + + connection.applicationID = 1024 + #expect(try connection.get(pragma: .applicationID) == 1024) + + try connection.set(pragma: .applicationID, value: 123) + #expect(connection.applicationID == 123) + } + + @Test func testForeignKeys() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + + #expect(connection.foreignKeys == false) + + connection.foreignKeys = true + #expect(try connection.get(pragma: .foreignKeys) == true) + + try connection.set(pragma: .foreignKeys, value: false) + #expect(connection.foreignKeys == false) + } + + @Test func testJournalMode() throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + + let connection = try Connection(path: path, options: [.create, .readwrite]) + + connection.journalMode = .delete + #expect(try connection.get(pragma: .journalMode) == JournalMode.delete) + + try connection.set(pragma: .journalMode, value: JournalMode.wal) + #expect(connection.journalMode == .wal) + } + + @Test func testSynchronous() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + + connection.synchronous = .normal + #expect(try connection.get(pragma: .synchronous) == Synchronous.normal) + + try connection.set(pragma: .synchronous, value: Synchronous.full) + #expect(connection.synchronous == .full) + } + + @Test func testUserVersion() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + + connection.userVersion = 42 + #expect(try connection.get(pragma: .userVersion) == 42) + + try connection.set(pragma: .userVersion, value: 13) + #expect(connection.userVersion == 13) + } + + @Test(arguments: ["main", nil]) + func applyKeyEncrypt(_ name: String?) throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + + do { + let connection = try Connection(path: path, options: [.create, .readwrite]) + try connection.apply(.passphrase("test"), name: name) + try connection.execute(sql: "CREATE TABLE t (id INT PRIMARY KEY)") + } + + do { + var connection: OpaquePointer! + sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, nil) + let status = sqlite3_exec( + connection, "SELECT count(*) FROM sqlite_master", nil, nil, nil + ) + #expect(status == SQLITE_NOTADB) + } + } + + @Test(arguments: ["main", nil]) + func applyKeyDecrypt(_ name: String?) throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + + do { + var connection: OpaquePointer! + let options = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE + sqlite3_open_v2(path, &connection, options, nil) + if let name { + sqlite3_key_v2(connection, name, "test", Int32("test".utf8.count)) + } else { + sqlite3_key(connection, "test", Int32("test".utf8.count)) + } + sqlite3_exec( + connection, "CREATE TABLE t (id INT PRIMARY KEY)", nil, nil, nil + ) + sqlite3_close_v2(connection) + } + + do { + let connection = try Connection(path: path, options: [.readwrite]) + try connection.apply(.passphrase("test"), name: name) + try connection.execute(sql: "SELECT count(*) FROM sqlite_master") + } + } + + @Test(arguments: ["main", nil]) + func applyKeyInvalid(_ name: String?) throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + #expect( + throws: SQLiteError(code: SQLITE_MISUSE, message: ""), + performing: { try connection.apply(.passphrase(""), name: name) } + ) + } + + @Test(arguments: ["main", nil]) + func rekey(_ name: String?) throws { + let dir = FileManager.default.temporaryDirectory + let file = UUID().uuidString + let path = dir.appending(component: file).path + defer { try? FileManager.default.removeItem(atPath: path) } + + do { + let connection = try Connection(path: path, options: [.create, .readwrite]) + try connection.apply(.passphrase("old-test"), name: name) + try connection.execute(sql: "CREATE TABLE t (id INT PRIMARY KEY)") + } + + do { + let connection = try Connection(path: path, options: [.create, .readwrite]) + try connection.apply(.passphrase("old-test"), name: name) + try connection.rekey(.passphrase("new-test"), name: name) + } + + do { + let connection = try Connection(path: path, options: [.readwrite]) + try connection.apply(.passphrase("new-test"), name: name) + try connection.execute(sql: "SELECT count(*) FROM sqlite_master") + } + } + + @Test(arguments: ["main", nil]) + func rekeyInvalid(_ name: String?) throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + try connection.apply(.passphrase("test"), name: name) + try connection.execute(sql: "CREATE TABLE t (id INT PRIMARY KEY)") + + #expect( + throws: SQLiteError(code: SQLITE_ERROR, message: ""), + performing: { try connection.rekey(.passphrase(""), name: name) } + ) + } + + @Test func addDelegate() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + try connection.execute(sql: "CREATE TABLE t (id INT PRIMARY KEY)") + + let delegate = ConnectionDelegate() + connection.add(delegate: delegate) + + try connection.execute(sql: "INSERT INTO t (id) VALUES (1)") + #expect(delegate.didUpdate) + #expect(delegate.willCommit) + #expect(delegate.didRollback == false) + + delegate.reset() + delegate.error = SQLiteError(code: -1, message: "") + + try? connection.execute(sql: "INSERT INTO t (id) VALUES (2)") + #expect(delegate.didUpdate) + #expect(delegate.willCommit) + #expect(delegate.didRollback) + + delegate.reset() + connection.remove(delegate: delegate) + + try connection.execute(sql: "INSERT INTO t (id) VALUES (3)") + #expect(delegate.didUpdate == false) + #expect(delegate.willCommit == false) + #expect(delegate.didRollback == false) + } + + @Test func addTraceDelegate() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + try connection.execute(sql: "CREATE TABLE t (id INT PRIMARY KEY)") + + let delegate = ConnectionTraceDelegate() + connection.add(trace: delegate) + + try connection.execute(sql: "INSERT INTO t (id) VALUES (:id)") + #expect(delegate.expandedSQL == "INSERT INTO t (id) VALUES (NULL)") + #expect(delegate.unexpandedSQL == "INSERT INTO t (id) VALUES (:id)") + + delegate.reset() + connection.remove(trace: delegate) + + try connection.execute(sql: "INSERT INTO t (id) VALUES (:id)") + #expect(delegate.expandedSQL == nil) + #expect(delegate.unexpandedSQL == nil) + } + + @Test func addFunction() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + + try connection.add(function: TestFunction.self) + #expect(TestFunction.isInstalled) + + try connection.remove(function: TestFunction.self) + #expect(TestFunction.isInstalled == false) + } + + @Test func beginTransaction() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + #expect(connection.isAutocommit) + + try connection.beginTransaction() + #expect(connection.isAutocommit == false) + } + + @Test func commitTransaction() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + #expect(connection.isAutocommit) + + try connection.beginTransaction() + try connection.commitTransaction() + #expect(connection.isAutocommit) + } + + @Test func rollbackTransaction() throws { + let connection = try Connection( + location: .inMemory, options: [.create, .readwrite] + ) + #expect(connection.isAutocommit) + + try connection.beginTransaction() + try connection.rollbackTransaction() + #expect(connection.isAutocommit) + } +} + +private extension ConnectionTests { + final class ConnectionDelegate: DataLiteCore.ConnectionDelegate { + var error: Error? + + var didUpdate = false + var willCommit = false + var didRollback = false + + func reset() { + didUpdate = false + willCommit = false + didRollback = false + } + + func connection( + _ connection: any ConnectionProtocol, + didUpdate action: SQLiteAction + ) { + didUpdate = true + } + + func connectionWillCommit(_ connection: any ConnectionProtocol) throws { + willCommit = true + if let error { throw error } + } + + func connectionDidRollback(_ connection: any ConnectionProtocol) { + didRollback = true + } + } + + final class ConnectionTraceDelegate: DataLiteCore.ConnectionTraceDelegate { + var expandedSQL: String? + var unexpandedSQL: String? + + func reset() { + expandedSQL = nil + unexpandedSQL = nil + } + + func connection(_ connection: any ConnectionProtocol, trace sql: Trace) { + expandedSQL = sql.expandedSQL + unexpandedSQL = sql.unexpandedSQL + } + } + + final class TestFunction: DataLiteCore.Function { + nonisolated(unsafe) static var isInstalled = false + + override class func install( + db connection: OpaquePointer + ) throws(SQLiteError) { + isInstalled = true + } + + override class func uninstall( + db connection: OpaquePointer + ) throws(SQLiteError) { + isInstalled = false + } + } +} diff --git a/Tests/DataLiteCoreTests/Classes/Function+OptionsTests.swift b/Tests/DataLiteCoreTests/Classes/Function+OptionsTests.swift new file mode 100644 index 0000000..2813863 --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Function+OptionsTests.swift @@ -0,0 +1,57 @@ +import Testing +import DataLiteC +import DataLiteCore + +struct FunctionOptionsTests { + @Test func singleOption() { + #expect(Function.Options.deterministic.rawValue == SQLITE_DETERMINISTIC) + #expect(Function.Options.directonly.rawValue == SQLITE_DIRECTONLY) + #expect(Function.Options.innocuous.rawValue == SQLITE_INNOCUOUS) + } + + @Test func multipleOptions() { + let options: Function.Options = [.deterministic, .directonly] + #expect(options.contains(.deterministic)) + #expect(options.contains(.directonly)) + #expect(options.contains(.innocuous) == false) + } + + @Test func equalityAndHashability() { + let options1: Function.Options = [.deterministic, .innocuous] + let options2: Function.Options = [.deterministic, .innocuous] + #expect(options1 == options2) + + let hash1 = options1.hashValue + let hash2 = options2.hashValue + #expect(hash1 == hash2) + } + + @Test func emptyOptions() { + let options = Function.Options(rawValue: 0) + #expect(options.contains(.deterministic) == false) + #expect(options.contains(.directonly) == false) + #expect(options.contains(.innocuous) == false) + } + + @Test func rawValueInitialization() { + let rawValue: Int32 = SQLITE_DETERMINISTIC | SQLITE_INNOCUOUS + let options = Function.Options(rawValue: rawValue) + + #expect(options.contains(.deterministic)) + #expect(options.contains(.innocuous)) + #expect(options.contains(.directonly) == false) + } + + @Test func addingAndRemovingOptions() { + var options: Function.Options = [] + + options.insert(.deterministic) + #expect(options.contains(.deterministic)) + + options.insert(.directonly) + #expect(options.contains(.directonly)) + + options.remove(.deterministic) + #expect(options.contains(.deterministic) == false) + } +} diff --git a/Tests/DataLiteCoreTests/Classes/Function+RegexpTests.swift b/Tests/DataLiteCoreTests/Classes/Function+RegexpTests.swift new file mode 100644 index 0000000..95fe920 --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Function+RegexpTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +import DataLiteCore + +struct FunctionRegexpTests { + @Test func metadata() { + #expect(Regexp.argc == 2) + #expect(Regexp.name == "REGEXP") + #expect(Regexp.options == [.deterministic, .innocuous]) + } + + @Test func invalidArguments() { + let arguments: Arguments = [.int(1), .text("value")] + #expect( + performing: { + try Regexp.invoke(args: arguments) + }, + throws: { + switch $0 { + case Regexp.Error.invalidArguments: + return true + default: + return false + } + } + ) + } + + @Test func invalidPattern() { + let arguments: Arguments = [.text("("), .text("value")] + #expect( + performing: { + try Regexp.invoke(args: arguments) + }, + throws: { + switch $0 { + case Regexp.Error.regexError: + return true + default: + return false + } + } + ) + } + + @Test func matchesPattern() throws { + let arguments: Arguments = [.text("foo.*"), .text("foobar")] + #expect(try Regexp.invoke(args: arguments) as? Bool == true) + } + + @Test func doesNotMatchPattern() throws { + let arguments: Arguments = [.text("bar.*"), .text("foobar")] + #expect(try Regexp.invoke(args: arguments) as? Bool == false) + } +} + +private extension FunctionRegexpTests { + typealias Regexp = Function.Regexp + + struct Arguments: ArgumentsProtocol, ExpressibleByArrayLiteral { + private let values: [SQLiteValue] + + var startIndex: Int { values.startIndex } + var endIndex: Int { values.endIndex } + + init(_ values: [SQLiteValue]) { + self.values = values + } + + init(arrayLiteral elements: SQLiteValue...) { + self.values = elements + } + + subscript(index: Int) -> SQLiteValue { + values[index] + } + + func index(after i: Int) -> Int { + values.index(after: i) + } + } +} diff --git a/Tests/DataLiteCoreTests/Classes/Statement+OptionsTests.swift b/Tests/DataLiteCoreTests/Classes/Statement+OptionsTests.swift new file mode 100644 index 0000000..5b31f8e --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/Statement+OptionsTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import DataLiteC +import DataLiteCore + +struct StatementOptionsTests { + @Test func persistentOptions() { + #expect(Statement.Options.persistent.rawValue == UInt32(SQLITE_PREPARE_PERSISTENT)) + } + + @Test func noVtabOptions() { + #expect(Statement.Options.noVtab.rawValue == UInt32(SQLITE_PREPARE_NO_VTAB)) + } + + @Test func combineOptions() { + let options: Statement.Options = [.persistent, .noVtab] + let expected = UInt32(SQLITE_PREPARE_PERSISTENT | SQLITE_PREPARE_NO_VTAB) + #expect(options.contains(.persistent)) + #expect(options.contains(.noVtab)) + #expect(options.rawValue == expected) + } + + @Test func initWithUInt32RawValue() { + let raw = UInt32(SQLITE_PREPARE_PERSISTENT) + let options = Statement.Options(rawValue: raw) + #expect(options == .persistent) + } + + @Test func initWithInt32RawValue() { + let raw = Int32(SQLITE_PREPARE_NO_VTAB) + let options = Statement.Options(rawValue: raw) + #expect(options == .noVtab) + } + + @Test func emptySetRawValueIsZero() { + let empty: Statement.Options = [] + #expect(empty.rawValue == 0) + #expect(!empty.contains(.persistent)) + #expect(!empty.contains(.noVtab)) + } +} diff --git a/Tests/DataLiteCoreTests/Classes/StatementTests.swift b/Tests/DataLiteCoreTests/Classes/StatementTests.swift new file mode 100644 index 0000000..629525b --- /dev/null +++ b/Tests/DataLiteCoreTests/Classes/StatementTests.swift @@ -0,0 +1,381 @@ +import Foundation +import Testing +import DataLiteC + +@testable import DataLiteCore + +final class StatementTests { + let connection: OpaquePointer + + init() { + var connection: OpaquePointer! = nil + let opts = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE + sqlite3_open_v2(":memory:", &connection, opts, nil) + sqlite3_exec( + connection, + """ + CREATE TABLE t( + id INTEGER PRIMARY KEY, + n INTEGER, + r REAL, + s TEXT, + b BLOB + ); + """, nil, nil, nil + ) + self.connection = connection + } + + deinit { + sqlite3_close_v2(connection) + } + + @Test func initWithError() throws { + #expect( + throws: SQLiteError( + code: SQLITE_ERROR, + message: "no such table: invalid" + ), + performing: { + try Statement( + db: connection, + sql: "SELECT * FROM invalid", + options: [] + ) + } + ) + } + + @Test func sqlString() throws { + let sql = "SELECT * FROM t WHERE id = ?" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.sql == sql) + } + + @Test(arguments: [ + ("SELECT * FROM t WHERE id = ? AND s = ?", 2), + ("SELECT * FROM t WHERE id = 1 AND s = ''", 0) + ]) + func parameterCount(_ sql: String, _ expanded: Int32) throws { + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.parameterCount() == expanded) + } + + @Test func parameterIndexByName() throws { + let sql = "SELECT * FROM t WHERE id = :id AND s = :s" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.parameterIndexBy(":id") == 1) + #expect(stmt.parameterIndexBy(":s") == 2) + #expect(stmt.parameterIndexBy(":invalid") == 0) + } + + @Test func parameterNameByIndex() throws { + let sql = "SELECT * FROM t WHERE id = :id AND s = :s" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.parameterNameBy(1) == ":id") + #expect(stmt.parameterNameBy(2) == ":s") + #expect(stmt.parameterNameBy(3) == nil) + } + + @Test func bindValueAtIndex() throws { + let sql = "SELECT * FROM t WHERE id = ?" + + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + + try stmt.bind(.int(42), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42") + + try stmt.bind(.real(42), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42.0") + + try stmt.bind(.text("42"), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = '42'") + + try stmt.bind(.blob(Data([0x42])), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = x'42'") + + try stmt.bind(.null, at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + + try stmt.bind(TestValue(value: 42), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42") + + try stmt.bind(TestValue?.none, at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + } + + @Test func errorBindValueAtIndex() throws { + let sql = "SELECT * FROM t WHERE id = ?" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect( + throws: SQLiteError( + code: SQLITE_RANGE, + message: "column index out of range" + ), + performing: { + try stmt.bind(.null, at: 0) + } + ) + } + + @Test func bindValueByName() throws { + let sql = "SELECT * FROM t WHERE id = :id" + + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + + try stmt.bind(.int(42), by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42") + + try stmt.bind(.real(42), by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42.0") + + try stmt.bind(.text("42"), by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = '42'") + + try stmt.bind(.blob(Data([0x42])), by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = x'42'") + + try stmt.bind(.null, by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + + try stmt.bind(TestValue(value: 42), by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42") + + try stmt.bind(TestValue?.none, by: ":id") + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + } + + @Test func errorBindValueByName() throws { + let sql = "SELECT * FROM t WHERE id = :id" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect( + throws: SQLiteError( + code: SQLITE_RANGE, + message: "column index out of range" + ), + performing: { + try stmt.bind(.null, by: ":invalid") + } + ) + } + + @Test func bindRow() throws { + let row: SQLiteRow = ["id": .int(42), "name": .text("Alice")] + let sql = "SELECT * FROM t WHERE id = :id AND s = :name" + + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL AND s = NULL") + + try stmt.bind(row) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42 AND s = 'Alice'") + } + + @Test func errorBindRow() throws { + let row: SQLiteRow = ["name": .text("Alice")] + let stmt = try Statement( + db: connection, sql: "SELECT * FROM t", options: [] + ) + #expect( + throws: SQLiteError( + code: SQLITE_RANGE, + message: "column index out of range" + ), + performing: { + try stmt.bind(row) + } + ) + } + + @Test func clearBindings() throws { + let sql = "SELECT * FROM t WHERE id = :id" + let stmt = try Statement(db: connection, sql: sql, options: []) + + try stmt.bind(.int(42), at: 1) + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = 42") + + try stmt.clearBindings() + #expect(stmt.expandedSQL == "SELECT * FROM t WHERE id = NULL") + } + + @Test func stepOneRow() throws { + let sql = "SELECT 1 WHERE 1" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(try stmt.step()) + #expect(try stmt.step() == false) + } + + @Test func stepMultipleRows() throws { + sqlite3_exec(connection, "INSERT INTO t(n) VALUES (1),(2),(3)", nil, nil, nil) + let sql = "SELECT id FROM t ORDER BY id" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(try stmt.step()) + #expect(try stmt.step()) + #expect(try stmt.step()) + #expect(try stmt.step() == false) + } + + @Test func stepNoRows() throws { + let sql = "SELECT 1 WHERE 0" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(try stmt.step() == false) + } + + @Test func stepWithError() throws { + sqlite3_exec(connection, "INSERT INTO t(id, n) VALUES (1, 10)", nil, nil, nil) + let sql = "INSERT INTO t(id, n) VALUES (?, ?)" + let stmt = try Statement(db: connection, sql: sql, options: []) + try stmt.bind(.int(1), at: 1) + try stmt.bind(.int(20), at: 2) + #expect( + throws: SQLiteError( + code: 1555, + message: "UNIQUE constraint failed: t.id" + ), + performing: { + try stmt.step() + } + ) + } + + @Test func executeRows() throws { + let rows: [SQLiteRow] = [ + [ + "id": .int(1), + "n": .int(42), + "r": .real(3.14), + "s": .text("Test"), + "b": .blob(Data([0x42])) + ], + [ + "id": .int(2), + "n": .null, + "r": .null, + "s": .null, + "b": .null + ] + ] + let sql = "INSERT INTO t(id, n, r, s, b) VALUES (:id, :n, :r, :s, :b)" + try Statement(db: connection, sql: sql, options: []).execute(rows) + + let stmt = try Statement(db: connection, sql: "SELECT * FROM t", options: []) + + #expect(try stmt.step()) + #expect(stmt.currentRow() == rows[0]) + + #expect(try stmt.step()) + #expect(stmt.currentRow() == rows[1]) + + #expect(try stmt.step() == false) + } + + @Test func executeEmptyRows() throws { + let sql = "INSERT INTO t(id, n, r, s, b) VALUES (:id, :n, :r, :s, :b)" + try Statement(db: connection, sql: sql, options: []).execute([]) + + let stmt = try Statement(db: connection, sql: "SELECT * FROM t", options: []) + #expect(try stmt.step() == false) + } + + @Test func columnCount() throws { + let sql = "SELECT * FROM t" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.columnCount() == 5) + } + + @Test func columnName() throws { + let sql = "SELECT * FROM t" + let stmt = try Statement(db: connection, sql: sql, options: []) + #expect(stmt.columnName(at: 0) == "id") + #expect(stmt.columnName(at: 1) == "n") + #expect(stmt.columnName(at: 2) == "r") + #expect(stmt.columnName(at: 3) == "s") + #expect(stmt.columnName(at: 4) == "b") + #expect(stmt.columnName(at: 5) == nil) + } + + @Test func columnValueAtIndex() throws { + sqlite3_exec( + connection, + """ + INSERT INTO t (id, n, r, s, b) + VALUES (10, 42, 3.5, 'hello', x'DEADBEEF') + """, nil, nil, nil + ) + + let sql = "SELECT * FROM t WHERE id = 10" + let stmt = try Statement(db: connection, sql: sql, options: []) + + #expect(try stmt.step()) + #expect(stmt.columnValue(at: 0) == .int(10)) + #expect(stmt.columnValue(at: 1) == .int(42)) + #expect(stmt.columnValue(at: 1) == TestValue(value: 42)) + #expect(stmt.columnValue(at: 2) == .real(3.5)) + #expect(stmt.columnValue(at: 3) == .text("hello")) + #expect(stmt.columnValue(at: 4) == .blob(Data([0xDE, 0xAD, 0xBE, 0xEF]))) + } + + @Test func columnNullValueAtIndex() throws { + sqlite3_exec( + connection, + """ + INSERT INTO t (id) VALUES (10) + """, nil, nil, nil + ) + + let sql = "SELECT * FROM t WHERE id = 10" + let stmt = try Statement(db: connection, sql: sql, options: []) + + #expect(try stmt.step()) + #expect(stmt.columnValue(at: 0) == .int(10)) + #expect(stmt.columnValue(at: 1) == .null) + #expect(stmt.columnValue(at: 1) == TestValue?.none) + } + + @Test func currentRow() throws { + sqlite3_exec( + connection, + """ + INSERT INTO t (id, n, r, s, b) + VALUES (10, 42, 3.5, 'hello', x'DEADBEEF') + """, nil, nil, nil + ) + + let row: SQLiteRow = [ + "id": .int(10), + "n": .int(42), + "r": .real(3.5), + "s": .text("hello"), + "b": .blob(Data([0xDE, 0xAD, 0xBE, 0xEF])) + ] + + let sql = "SELECT * FROM t WHERE id = 10" + let stmt = try Statement(db: connection, sql: sql, options: []) + + #expect(try stmt.step()) + #expect(stmt.currentRow() == row) + } +} + +private extension StatementTests { + struct TestValue: SQLiteRepresentable, Equatable { + let value: Int + + var sqliteValue: SQLiteValue { + .int(Int64(value)) + } + + init(value: Int) { + self.value = value + } + + init?(_ value: SQLiteValue) { + if case .int(let intValue) = value { + self.value = Int(intValue) + } else { + return nil + } + } + } +} diff --git a/Tests/DataLiteCoreTests/Enums/JournalModeTests.swift b/Tests/DataLiteCoreTests/Enums/JournalModeTests.swift new file mode 100644 index 0000000..f23dfce --- /dev/null +++ b/Tests/DataLiteCoreTests/Enums/JournalModeTests.swift @@ -0,0 +1,33 @@ +import Testing +import DataLiteCore + +struct JournalModeTests { + @Test func rawValue() { + #expect(JournalMode.delete.rawValue == "DELETE") + #expect(JournalMode.truncate.rawValue == "TRUNCATE") + #expect(JournalMode.persist.rawValue == "PERSIST") + #expect(JournalMode.memory.rawValue == "MEMORY") + #expect(JournalMode.wal.rawValue == "WAL") + #expect(JournalMode.off.rawValue == "OFF") + } + + @Test func initRawValue() { + #expect(JournalMode(rawValue: "DELETE") == .delete) + #expect(JournalMode(rawValue: "delete") == .delete) + + #expect(JournalMode(rawValue: "TRUNCATE") == .truncate) + #expect(JournalMode(rawValue: "truncate") == .truncate) + + #expect(JournalMode(rawValue: "PERSIST") == .persist) + #expect(JournalMode(rawValue: "persist") == .persist) + + #expect(JournalMode(rawValue: "MEMORY") == .memory) + #expect(JournalMode(rawValue: "memory") == .memory) + + #expect(JournalMode(rawValue: "WAL") == .wal) + #expect(JournalMode(rawValue: "wal") == .wal) + + #expect(JournalMode(rawValue: "OFF") == .off) + #expect(JournalMode(rawValue: "off") == .off) + } +} diff --git a/Tests/DataLiteCoreTests/Enums/SQLiteValueTests.swift b/Tests/DataLiteCoreTests/Enums/SQLiteValueTests.swift new file mode 100644 index 0000000..435cc56 --- /dev/null +++ b/Tests/DataLiteCoreTests/Enums/SQLiteValueTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +import DataLiteCore + +struct SQLiteValueTests { + @Test(arguments: [1, 42, 1234]) + func intSQLiteValue(_ value: Int64) { + let value = SQLiteValue.int(value) + #expect(value.sqliteLiteral == "\(value)") + #expect(value.description == value.sqliteLiteral) + } + + @Test(arguments: [12, 0.5, 123.99]) + func realSQLiteValue(_ value: Double) { + let value = SQLiteValue.real(value) + #expect(value.sqliteLiteral == "\(value)") + #expect(value.description == value.sqliteLiteral) + } + + @Test(arguments: [ + ("", "''"), + ("'hello'", "'''hello'''"), + ("hello", "'hello'"), + ("O'Reilly", "'O''Reilly'"), + ("It's John's \"book\"", "'It''s John''s \"book\"'") + ]) + func textSQLiteValue(_ value: String, _ expected: String) { + let value = SQLiteValue.text(value) + #expect(value.sqliteLiteral == expected) + #expect(value.description == value.sqliteLiteral) + } + + @Test(arguments: [ + (Data(), "X''"), + (Data([0x00]), "X'00'"), + (Data([0x00, 0xAB, 0xCD]), "X'00ABCD'") + ]) + func blobSQLiteValue(_ value: Data, _ expected: String) { + let value = SQLiteValue.blob(value) + #expect(value.sqliteLiteral == expected) + #expect(value.description == value.sqliteLiteral) + } + + @Test func nullSQLiteValue() { + let value = SQLiteValue.null + #expect(value.sqliteLiteral == "NULL") + #expect(value.description == value.sqliteLiteral) + } +} diff --git a/Tests/DataLiteCoreTests/Enums/TransactionTypeTests.swift b/Tests/DataLiteCoreTests/Enums/TransactionTypeTests.swift new file mode 100644 index 0000000..0dc499a --- /dev/null +++ b/Tests/DataLiteCoreTests/Enums/TransactionTypeTests.swift @@ -0,0 +1,29 @@ +import Testing +import DataLiteCore + +struct TransactionTypeTests { + @Test func description() { + #expect(TransactionType.deferred.description == "DEFERRED") + #expect(TransactionType.immediate.description == "IMMEDIATE") + #expect(TransactionType.exclusive.description == "EXCLUSIVE") + } + + @Test func rawValue() { + #expect(TransactionType.deferred.rawValue == "DEFERRED") + #expect(TransactionType.immediate.rawValue == "IMMEDIATE") + #expect(TransactionType.exclusive.rawValue == "EXCLUSIVE") + } + + @Test func initRawValue() { + #expect(TransactionType(rawValue: "DEFERRED") == .deferred) + #expect(TransactionType(rawValue: "deferred") == .deferred) + + #expect(TransactionType(rawValue: "IMMEDIATE") == .immediate) + #expect(TransactionType(rawValue: "immediate") == .immediate) + + #expect(TransactionType(rawValue: "EXCLUSIVE") == .exclusive) + #expect(TransactionType(rawValue: "exclusive") == .exclusive) + + #expect(TransactionType(rawValue: "SOME_STR") == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/BinaryFloatingPointTests.swift b/Tests/DataLiteCoreTests/Extensions/BinaryFloatingPointTests.swift new file mode 100644 index 0000000..bc79f69 --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/BinaryFloatingPointTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +import DataLiteCore + +struct BinaryFloatingPointTests { + @Test func floatingPointToSQLiteValue() { + #expect(Float(3.14).sqliteValue == .real(Double(Float(3.14)))) + #expect(Double(3.14).sqliteValue == .real(3.14)) + } + + @Test func floatingPointFromSQLiteValue() { + #expect(Float(SQLiteValue.real(3.14)) == 3.14) + #expect(Float(SQLiteValue.int(42)) == 42) + + #expect(Double(SQLiteValue.real(3.14)) == 3.14) + #expect(Double(SQLiteValue.int(42)) == 42) + + #expect(Float(SQLiteValue.text("42")) == nil) + #expect(Float(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Float(SQLiteValue.null) == nil) + + #expect(Double(SQLiteValue.text("42")) == nil) + #expect(Double(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Double(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/BinaryIntegerTests.swift b/Tests/DataLiteCoreTests/Extensions/BinaryIntegerTests.swift new file mode 100644 index 0000000..1fbeeee --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/BinaryIntegerTests.swift @@ -0,0 +1,89 @@ +import Testing +import Foundation +import DataLiteCore + +struct BinaryIntegerTests { + @Test func integerToSQLiteValue() { + #expect(Int(42).sqliteValue == .int(42)) + #expect(Int8(42).sqliteValue == .int(42)) + #expect(Int16(42).sqliteValue == .int(42)) + #expect(Int32(42).sqliteValue == .int(42)) + #expect(Int64(42).sqliteValue == .int(42)) + + #expect(UInt(42).sqliteValue == .int(42)) + #expect(UInt8(42).sqliteValue == .int(42)) + #expect(UInt16(42).sqliteValue == .int(42)) + #expect(UInt32(42).sqliteValue == .int(42)) + #expect(UInt64(42).sqliteValue == .int(42)) + } + + @Test func integerFromSQLiteValue() { + #expect(Int(SQLiteValue.int(42)) == 42) + #expect(Int8(SQLiteValue.int(42)) == 42) + #expect(Int16(SQLiteValue.int(42)) == 42) + #expect(Int32(SQLiteValue.int(42)) == 42) + #expect(Int64(SQLiteValue.int(42)) == 42) + + #expect(UInt(SQLiteValue.int(42)) == 42) + #expect(UInt8(SQLiteValue.int(42)) == 42) + #expect(UInt16(SQLiteValue.int(42)) == 42) + #expect(UInt32(SQLiteValue.int(42)) == 42) + #expect(UInt64(SQLiteValue.int(42)) == 42) + + #expect(Int(SQLiteValue.real(42)) == nil) + #expect(Int(SQLiteValue.text("42")) == nil) + #expect(Int(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Int(SQLiteValue.null) == nil) + + #expect(Int8(SQLiteValue.real(42)) == nil) + #expect(Int8(SQLiteValue.text("42")) == nil) + #expect(Int8(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Int8(SQLiteValue.null) == nil) + #expect(Int8(SQLiteValue.int(Int64.max)) == nil) + + #expect(Int16(SQLiteValue.real(42)) == nil) + #expect(Int16(SQLiteValue.text("42")) == nil) + #expect(Int16(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Int16(SQLiteValue.null) == nil) + #expect(Int16(SQLiteValue.int(Int64.max)) == nil) + + #expect(Int32(SQLiteValue.real(42)) == nil) + #expect(Int32(SQLiteValue.text("42")) == nil) + #expect(Int32(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Int32(SQLiteValue.null) == nil) + #expect(Int32(SQLiteValue.int(Int64.max)) == nil) + + #expect(Int64(SQLiteValue.real(42)) == nil) + #expect(Int64(SQLiteValue.text("42")) == nil) + #expect(Int64(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(Int64(SQLiteValue.null) == nil) + + #expect(UInt(SQLiteValue.real(42)) == nil) + #expect(UInt(SQLiteValue.text("42")) == nil) + #expect(UInt(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UInt(SQLiteValue.null) == nil) + + #expect(UInt8(SQLiteValue.real(42)) == nil) + #expect(UInt8(SQLiteValue.text("42")) == nil) + #expect(UInt8(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UInt8(SQLiteValue.null) == nil) + #expect(UInt8(SQLiteValue.int(Int64.max)) == nil) + + #expect(UInt16(SQLiteValue.real(42)) == nil) + #expect(UInt16(SQLiteValue.text("42")) == nil) + #expect(UInt16(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UInt16(SQLiteValue.null) == nil) + #expect(UInt16(SQLiteValue.int(Int64.max)) == nil) + + #expect(UInt32(SQLiteValue.real(42)) == nil) + #expect(UInt32(SQLiteValue.text("42")) == nil) + #expect(UInt32(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UInt32(SQLiteValue.null) == nil) + #expect(UInt32(SQLiteValue.int(Int64.max)) == nil) + + #expect(UInt64(SQLiteValue.real(42)) == nil) + #expect(UInt64(SQLiteValue.text("42")) == nil) + #expect(UInt64(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UInt64(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/BoolTests.swift b/Tests/DataLiteCoreTests/Extensions/BoolTests.swift new file mode 100644 index 0000000..c56ef2a --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/BoolTests.swift @@ -0,0 +1,22 @@ +import Testing +import Foundation +import DataLiteCore + +struct BoolTests { + @Test func boolToSQLiteValue() { + #expect(true.sqliteValue == .int(1)) + #expect(false.sqliteValue == .int(0)) + } + + @Test func boolFromSQLiteValue() { + #expect(Bool(SQLiteValue.int(1)) == true) + #expect(Bool(SQLiteValue.int(0)) == false) + + #expect(Bool(SQLiteValue.int(-1)) == nil) + #expect(Bool(SQLiteValue.int(2)) == nil) + #expect(Bool(SQLiteValue.real(1.0)) == nil) + #expect(Bool(SQLiteValue.text("true")) == nil) + #expect(Bool(SQLiteValue.blob(Data())) == nil) + #expect(Bool(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/DataTests.swift b/Tests/DataLiteCoreTests/Extensions/DataTests.swift new file mode 100644 index 0000000..2019c40 --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/DataTests.swift @@ -0,0 +1,20 @@ +import Testing +import Foundation +import DataLiteCore + +struct DataSQLiteRawRepresentableTests { + @Test func dataToSQLiteValue() { + let data = Data([0x01, 0x02, 0x03]) + #expect(data.sqliteValue == .blob(data)) + } + + @Test func dataFromSQLiteValue() { + let data = Data([0x01, 0x02, 0x03]) + #expect(Data(SQLiteValue.blob(data)) == data) + + #expect(Data(SQLiteValue.int(1)) == nil) + #expect(Data(SQLiteValue.real(1.0)) == nil) + #expect(Data(SQLiteValue.text("blob")) == nil) + #expect(Data(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/DateTests.swift b/Tests/DataLiteCoreTests/Extensions/DateTests.swift new file mode 100644 index 0000000..7ff6d72 --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/DateTests.swift @@ -0,0 +1,25 @@ +import Testing +import Foundation +import DataLiteCore + +struct DateSQLiteRawRepresentableTests { + @Test func dateToSQLiteValue() { + let date = Date(timeIntervalSince1970: 1609459200) + let dateString = "2021-01-01T00:00:00Z" + + #expect(date.sqliteValue == .text(dateString)) + } + + @Test func dateFromSQLiteValue() { + let date = Date(timeIntervalSince1970: 1609459200) + let dateString = "2021-01-01T00:00:00Z" + + #expect(Date(SQLiteValue.text(dateString)) == date) + #expect(Date(SQLiteValue.int(1609459200)) == date) + #expect(Date(SQLiteValue.real(1609459200)) == date) + + #expect(Date(SQLiteValue.blob(Data([0x01, 0x02, 0x03]))) == nil) + #expect(Date(SQLiteValue.null) == nil) + #expect(Date(SQLiteValue.text("Invalid date format")) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/RawRepresentableTests.swift b/Tests/DataLiteCoreTests/Extensions/RawRepresentableTests.swift new file mode 100644 index 0000000..e6589bd --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/RawRepresentableTests.swift @@ -0,0 +1,34 @@ +import Testing +import Foundation +import DataLiteCore + +struct RawRepresentableTests { + @Test func rawRepresentableToSQLiteValue() { + #expect(Color.red.sqliteValue == .int(Color.red.rawValue)) + #expect(Color.green.sqliteValue == .int(Color.green.rawValue)) + #expect(Color.blue.sqliteValue == .int(Color.blue.rawValue)) + } + + @Test func rawRepresentableFromSQLiteValue() { + #expect(Color(SQLiteValue.int(0)) == .red) + #expect(Color(SQLiteValue.int(1)) == .green) + #expect(Color(SQLiteValue.int(2)) == .blue) + + #expect(Color(SQLiteValue.int(42)) == nil) + #expect(Color(SQLiteValue.real(0)) == nil) + #expect(Color(SQLiteValue.real(1)) == nil) + #expect(Color(SQLiteValue.real(2)) == nil) + #expect(Color(SQLiteValue.real(42)) == nil) + #expect(Color(SQLiteValue.text("red")) == nil) + #expect(Color(SQLiteValue.blob(Data([0x01, 0x02]))) == nil) + #expect(Color(SQLiteValue.null) == nil) + } +} + +private extension RawRepresentableTests { + enum Color: Int64, SQLiteRepresentable { + case red + case green + case blue + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/StringTests.swift b/Tests/DataLiteCoreTests/Extensions/StringTests.swift new file mode 100644 index 0000000..1d64372 --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/StringTests.swift @@ -0,0 +1,18 @@ +import Testing +import Foundation +import DataLiteCore + +struct StringTests { + @Test func stringToSQLiteValue() { + #expect("Hello, SQLite!".sqliteValue == .text("Hello, SQLite!")) + } + + @Test func stringFromSQLiteValue() { + #expect(String(SQLiteValue.text("Hello, SQLite!")) == "Hello, SQLite!") + + #expect(String(SQLiteValue.int(42)) == nil) + #expect(String(SQLiteValue.real(42)) == nil) + #expect(String(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(String(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Extensions/UUIDTests.swift b/Tests/DataLiteCoreTests/Extensions/UUIDTests.swift new file mode 100644 index 0000000..194ee02 --- /dev/null +++ b/Tests/DataLiteCoreTests/Extensions/UUIDTests.swift @@ -0,0 +1,21 @@ +import Testing +import Foundation +import DataLiteCore + +struct UUIDTests { + @Test func uuidToSQLiteValue() { + let uuid = UUID(uuidString: "123e4567-e89b-12d3-a456-426614174000")! + #expect(uuid.sqliteValue == .text("123E4567-E89B-12D3-A456-426614174000")) + } + + @Test func uuidFromSQLiteValue() { + let raw = SQLiteValue.text("123e4567-e89b-12d3-a456-426614174000") + #expect(UUID(raw) == UUID(uuidString: "123e4567-e89b-12d3-a456-426614174000")) + + #expect(UUID(SQLiteValue.int(42)) == nil) + #expect(UUID(SQLiteValue.real(42)) == nil) + #expect(UUID(SQLiteValue.text("42")) == nil) + #expect(UUID(SQLiteValue.blob(Data([0x42]))) == nil) + #expect(UUID(SQLiteValue.null) == nil) + } +} diff --git a/Tests/DataLiteCoreTests/Protocols/ArgumentsProtocolTests.swift b/Tests/DataLiteCoreTests/Protocols/ArgumentsProtocolTests.swift new file mode 100644 index 0000000..cbd2311 --- /dev/null +++ b/Tests/DataLiteCoreTests/Protocols/ArgumentsProtocolTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +import DataLiteCore + +struct ArgumentsProtocolTests { + @Test func typedSubscript() { + let arguments: Arguments = [ + .text("one"), + .text("two"), + .int(42) + ] + + #expect(arguments[0] == TestModel.one) + #expect(arguments[1] == TestModel.two) + #expect(arguments[2] as TestModel? == nil) + } +} + +private extension ArgumentsProtocolTests { + enum TestModel: String, SQLiteRepresentable { + case one, two + } + + struct Arguments: ArgumentsProtocol, ExpressibleByArrayLiteral { + private let values: [SQLiteValue] + + var startIndex: Int { values.startIndex } + var endIndex: Int { values.endIndex } + + init(_ values: [SQLiteValue]) { + self.values = values + } + + init(arrayLiteral elements: SQLiteValue...) { + self.values = elements + } + + subscript(index: Int) -> SQLiteValue { + values[index] + } + + func index(after i: Int) -> Int { + values.index(after: i) + } + } +} diff --git a/Tests/DataLiteCoreTests/Protocols/SQLiteBindableTests.swift b/Tests/DataLiteCoreTests/Protocols/SQLiteBindableTests.swift new file mode 100644 index 0000000..a9d9cdb --- /dev/null +++ b/Tests/DataLiteCoreTests/Protocols/SQLiteBindableTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +import DataLiteCore + +struct SQLiteBindableTests { + @Test(arguments: [ + SQLiteValue.int(42), + SQLiteValue.real(0.5), + SQLiteValue.text("O'Reilly"), + SQLiteValue.blob(Data([0x00, 0xAB])), + SQLiteValue.null + ]) + func sqliteLiteral(_ value: SQLiteValue) { + let stub = Bindable(value: value) + #expect(stub.sqliteLiteral == value.sqliteLiteral) + } +} + +private extension SQLiteBindableTests { + struct Bindable: SQLiteBindable { + let value: SQLiteValue + var sqliteValue: SQLiteValue { value } + } +} diff --git a/Tests/DataLiteCoreTests/Structures/PragmaTests.swift b/Tests/DataLiteCoreTests/Structures/PragmaTests.swift new file mode 100644 index 0000000..78fb4fc --- /dev/null +++ b/Tests/DataLiteCoreTests/Structures/PragmaTests.swift @@ -0,0 +1,30 @@ +import Testing + +@testable import DataLiteCore + +struct PragmaTests { + @Test(arguments: [ + (Pragma.applicationID, "application_id"), + (Pragma.foreignKeys, "foreign_keys"), + (Pragma.journalMode, "journal_mode"), + (Pragma.synchronous, "synchronous"), + (Pragma.userVersion, "user_version"), + (Pragma.busyTimeout, "busy_timeout") + ]) + func predefinedPragmas(_ pragma: Pragma, _ expected: String) { + #expect(pragma.rawValue == expected) + #expect(pragma.description == expected) + } + + @Test func initRawValue() { + let pragma = Pragma(rawValue: "custom_pragma") + #expect(pragma.rawValue == "custom_pragma") + #expect(pragma.description == "custom_pragma") + } + + @Test func initStringLiteral() { + let pragma: Pragma = "another_pragma" + #expect(pragma.rawValue == "another_pragma") + #expect(pragma.description == "another_pragma") + } +} diff --git a/Tests/DataLiteCoreTests/Structures/SQLiteErrorTests.swift b/Tests/DataLiteCoreTests/Structures/SQLiteErrorTests.swift new file mode 100644 index 0000000..68c9950 --- /dev/null +++ b/Tests/DataLiteCoreTests/Structures/SQLiteErrorTests.swift @@ -0,0 +1,36 @@ +import Testing +import DataLiteC + +@testable import DataLiteCore + +struct SQLiteErrorTests { + @Test func initWithConnection() { + var connection: OpaquePointer! + defer { sqlite3_close(connection) } + sqlite3_open(":memory:", &connection) + sqlite3_exec(connection, "INVALID SQL", nil, nil, nil) + + let error = SQLiteError(connection) + #expect(error.code == SQLITE_ERROR) + #expect(error.message == "near \"INVALID\": syntax error") + } + + @Test func initWithCodeAndMessage() { + let error = SQLiteError(code: 1, message: "Test Error Message") + #expect(error.code == 1) + #expect(error.message == "Test Error Message") + } + + @Test func description() { + let error = SQLiteError(code: 1, message: "Test Error Message") + #expect(error.description == "SQLiteError(1): Test Error Message") + } + + @Test func equality() { + let lhs = SQLiteError(code: 1, message: "First") + let rhs = SQLiteError(code: 1, message: "First") + let different = SQLiteError(code: 2, message: "Second") + #expect(lhs == rhs) + #expect(lhs != different) + } +} diff --git a/Tests/DataLiteCoreTests/Structures/SQLiteRowTests.swift b/Tests/DataLiteCoreTests/Structures/SQLiteRowTests.swift new file mode 100644 index 0000000..fa2fca2 --- /dev/null +++ b/Tests/DataLiteCoreTests/Structures/SQLiteRowTests.swift @@ -0,0 +1,106 @@ +import Testing + +@testable import DataLiteCore + +struct SQLiteRowTests { + @Test func subscriptByColumn() { + var row = SQLiteRow() + #expect(row["name"] == nil) + + row["name"] = .text("Alice") + #expect(row["name"] == .text("Alice")) + + row["name"] = .text("Bob") + #expect(row["name"] == .text("Bob")) + + row["name"] = nil + #expect(row["name"] == nil) + } + + @Test func subscriptByIndex() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30), + "city": .text("Wonderland") + ] + #expect(row[0] == ("name", .text("Alice"))) + #expect(row[1] == ("age", .int(30))) + #expect(row[2] == ("city", .text("Wonderland"))) + } + + @Test func columns() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30), + "city": .text("Wonderland") + ] + #expect(row.columns == ["name", "age", "city"]) + } + + @Test func namedParameters() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30), + "city": .text("Wonderland") + ] + #expect(row.namedParameters == [":name", ":age", ":city"]) + } + + @Test func containsColumn() { + let row: SQLiteRow = ["one": .null] + #expect(row.contains("one")) + #expect(row.contains("two") == false) + } + + @Test func description() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30) + ] + #expect(row.description == #"["name": 'Alice', "age": 30]"#) + } + + @Test func startIndex() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30) + ] + #expect(row.startIndex == 0) + } + + @Test func endIndex() { + let row: SQLiteRow = [ + "name": .text("Alice"), + "age": .int(30) + ] + #expect(row.endIndex == 2) + } + + @Test func endIndexEmptyRow() { + let row = SQLiteRow() + #expect(row.endIndex == 0) + } + + @Test func isEmpty() { + var row = SQLiteRow() + #expect(row.isEmpty) + + row["one"] = .int(1) + #expect(row.isEmpty == false) + } + + @Test func count() { + var row = SQLiteRow() + #expect(row.count == 0) + + row["one"] = .int(1) + #expect(row.count == 1) + } + + @Test func indexAfter() { + let row = SQLiteRow() + #expect(row.index(after: 0) == 1) + #expect(row.index(after: 1) == 2) + #expect(row.index(after: 2) == 3) + } +}