diff --git a/Package.swift b/Package.swift index f652185..5202f8c 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,10 @@ let package = Package( .library( name: "LSQLite", targets: ["LSQLite"] + ), + .library( + name: "LSQLiteExtensions", + targets: ["LSQLiteExtensions"] ) ], targets: [ @@ -15,6 +19,10 @@ let package = Package( name: "LSQLite", dependencies: ["MissedSwiftSQLite"] ), + .target( + name: "LSQLiteExtensions", + dependencies: ["LSQLite"] + ), .target( name: "MissedSwiftSQLite" ), @@ -22,5 +30,9 @@ let package = Package( name: "LSQLiteTests", dependencies: ["LSQLite", "MissedSwiftSQLite"] ), + .testTarget( + name: "LSQLiteExtensionsTests", + dependencies: ["LSQLiteExtensions"] + ), ] ) diff --git a/README.md b/README.md index 88a3588..655a0b0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A zero-overhead, typed Swift wrapper around the SQLite C API — same functions, no `OpaquePointer`, no magic constants. +LSQLiteExtensions is an add-on target in this package that layers opt-in conveniences on top of LSQLite while keeping SQLite semantics intact. It focuses on reducing boilerplate for common workflows (for example, Codable binding and row decoding) without introducing higher-level abstractions or a throwing error model. + ## Motivation The SQLite C API is small and powerful, but in Swift it comes with a few pain points: diff --git a/Sources/LSQLiteExtensions/Coding/Binding/Statement+Binding.swift b/Sources/LSQLiteExtensions/Coding/Binding/Statement+Binding.swift new file mode 100644 index 0000000..378f5a9 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Binding/Statement+Binding.swift @@ -0,0 +1,37 @@ +import Foundation +import LSQLite + +extension Statement { + /// Encodes and binds a value to the statement's named parameters. + /// - Parameter binding: Value to encode and bind. + /// - Returns: `true` when all parameters match and binding succeeds; otherwise `false`. + /// + /// Only top-level keyed containers are supported. Coding keys must match the + /// statement parameter names after removing the leading ":" prefix. Keys + /// must be emitted for every parameter; optional values must encode `nil` + /// explicitly. Supported value types are `nil`, `Data`, `String`, `Int` + /// (64-bit), and `Double`. + /// + /// Related SQLite: `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob` + public func bind(_ binding: Binding) -> Bool { + do { + let parameterMap = try statementParameterMap(for: self) + let keyCollector = StatementKeyCollectorEncoder() + try binding.encode(to: keyCollector) + if keyCollector.failure != nil { + return false + } + if keyCollector.keys != Set(parameterMap.keys) { + return false + } + let encoder = StatementBindingEncoder(statement: self, parameterMap: parameterMap) + try binding.encode(to: encoder) + if encoder.failure != nil { + return false + } + return true + } catch { + return false + } + } +} diff --git a/Sources/LSQLiteExtensions/Coding/Binding/StatementBindingEncoder.swift b/Sources/LSQLiteExtensions/Coding/Binding/StatementBindingEncoder.swift new file mode 100644 index 0000000..3debe21 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Binding/StatementBindingEncoder.swift @@ -0,0 +1,172 @@ +import Foundation +import LSQLite + +final class StatementBindingEncoder: Encoder { + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var failure: StatementCodingFailure? + let statement: Statement + let parameterMap: [String: Int32] + + init(statement: Statement, parameterMap: [String: Int32]) { + self.statement = statement + self.parameterMap = parameterMap + } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + return KeyedEncodingContainer(StatementBindingContainer(encoder: self)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + fail(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: fail) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + fail(.unsupportedContainer) + return StatementFailingSingleValueEncodingContainer(codingPath: codingPath, failure: fail) + } + + func fail(_ error: StatementCodingFailure) { + if failure == nil { + failure = error + } + } +} + +struct StatementBindingContainer: KeyedEncodingContainerProtocol { + var codingPath: [CodingKey] { encoder.codingPath } + let encoder: StatementBindingEncoder + + init(encoder: StatementBindingEncoder) { + self.encoder = encoder + } + + mutating func encodeNil(forKey key: Key) throws { + let index = try parameterIndex(for: key) + try bindResult(encoder.statement.bindNull(at: index)) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: String, forKey key: Key) throws { + let index = try parameterIndex(for: key) + try bindResult(encoder.statement.bindText(value, at: index)) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + let index = try parameterIndex(for: key) + try bindResult(encoder.statement.bindDouble(value, at: index)) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + let index = try parameterIndex(for: key) + try bindResult(encoder.statement.bindInt64(Int64(value), at: index)) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + try unsupportedValue() + } + + mutating func encode(_ value: T, forKey key: Key) throws { + if let data = value as? Data { + let index = try parameterIndex(for: key) + try bindData(data, at: index) + return + } + try unsupportedValue() + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + encoder.fail(.unsupportedContainer) + return KeyedEncodingContainer(StatementFailingKeyedEncodingContainer(codingPath: codingPath, failure: encoder.fail)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + encoder.fail(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: encoder.fail) + } + + mutating func superEncoder() -> Encoder { + encoder.fail(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: encoder.fail) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + encoder.fail(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: encoder.fail) + } + + func parameterIndex(for key: Key) throws -> Int32 { + let name = key.stringValue + guard let index = encoder.parameterMap[name] else { + encoder.fail(.invalidParameter) + throw StatementCodingFailure.invalidParameter + } + return index + } + + func bindResult(_ result: ResultCode) throws { + guard result == .ok else { + encoder.fail(.unsupportedValue) + throw StatementCodingFailure.unsupportedValue + } + } + + func bindData(_ data: Data, at index: Int32) throws { + if data.isEmpty { + try bindResult(encoder.statement.bindZeroBlob(length: 0, at: index)) + return + } + let result: ResultCode = data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return encoder.statement.bindZeroBlob(length: 0, at: index) + } + return encoder.statement.bindTransientBlob(baseAddress, length: Int32(buffer.count), at: index) + } + try bindResult(result) + } + + func unsupportedValue() throws { + encoder.fail(.unsupportedValue) + throw StatementCodingFailure.unsupportedValue + } +} diff --git a/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift b/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift new file mode 100644 index 0000000..8493d05 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift @@ -0,0 +1,71 @@ +import Foundation + +final class StatementKeyCollectorEncoder: Encoder { + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var keys: Set = [] + var failure: StatementCodingFailure? + + init() {} + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + return KeyedEncodingContainer(StatementKeyCollectorContainer(encoder: self)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + fail(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: fail) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + fail(.unsupportedContainer) + return StatementFailingSingleValueEncodingContainer(codingPath: codingPath, failure: fail) + } + + func record(_ key: Key) { + keys.insert(key.stringValue) + } + + func fail(_ error: StatementCodingFailure) { + if failure == nil { + failure = error + } + } +} + +struct StatementKeyCollectorContainer: KeyedEncodingContainerProtocol { + var codingPath: [CodingKey] { encoder.codingPath } + let encoder: StatementKeyCollectorEncoder + + init(encoder: StatementKeyCollectorEncoder) { + self.encoder = encoder + } + + mutating func encodeNil(forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: T, forKey key: Key) throws { + encoder.record(key) + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + encoder.fail(.unsupportedContainer) + return KeyedEncodingContainer(StatementFailingKeyedEncodingContainer(codingPath: codingPath, failure: encoder.fail)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + encoder.fail(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: encoder.fail) + } + + mutating func superEncoder() -> Encoder { + encoder.fail(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: encoder.fail) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + encoder.fail(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: encoder.fail) + } +} diff --git a/Sources/LSQLiteExtensions/Coding/Binding/StatementParameterMap.swift b/Sources/LSQLiteExtensions/Coding/Binding/StatementParameterMap.swift new file mode 100644 index 0000000..3e5a48d --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Binding/StatementParameterMap.swift @@ -0,0 +1,28 @@ +import LSQLite + +func statementParameterMap(for statement: Statement) throws -> [String: Int32] { + let count = Int(statement.bindingCount) + guard count > 0 else { + return [:] + } + var map: [String: Int32] = [:] + map.reserveCapacity(count) + for index in 1...count { + let index32 = Int32(index) + guard let name = statement.bindingName(at: index32) else { + throw StatementCodingFailure.invalidParameter + } + guard name.hasPrefix(":") else { + throw StatementCodingFailure.invalidParameter + } + let trimmed = String(name.dropFirst()) + guard !trimmed.isEmpty else { + throw StatementCodingFailure.invalidParameter + } + if map[trimmed] != nil { + throw StatementCodingFailure.duplicateParameter + } + map[trimmed] = index32 + } + return map +} diff --git a/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift b/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift new file mode 100644 index 0000000..3e2e326 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift @@ -0,0 +1,123 @@ +import Foundation + +struct StatementFailingKeyedEncodingContainer: KeyedEncodingContainerProtocol { + var codingPath: [CodingKey] + let failure: (StatementCodingFailure) -> Void + + init(codingPath: [CodingKey], failure: @escaping (StatementCodingFailure) -> Void) { + self.codingPath = codingPath + self.failure = failure + } + + mutating func encodeNil(forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: T, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + failure(.unsupportedContainer) + return KeyedEncodingContainer(StatementFailingKeyedEncodingContainer(codingPath: codingPath, failure: failure)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + failure(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: failure) + } + + mutating func superEncoder() -> Encoder { + failure(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: failure) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + failure(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: failure) + } +} + +struct StatementFailingUnkeyedEncodingContainer: UnkeyedEncodingContainer { + var codingPath: [CodingKey] + var count: Int = 0 + let failure: (StatementCodingFailure) -> Void + + init(codingPath: [CodingKey], failure: @escaping (StatementCodingFailure) -> Void) { + self.codingPath = codingPath + self.failure = failure + } + + mutating func encodeNil() throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: T) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type) -> KeyedEncodingContainer { + failure(.unsupportedContainer) + return KeyedEncodingContainer(StatementFailingKeyedEncodingContainer(codingPath: codingPath, failure: failure)) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + failure(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: failure) + } + + mutating func superEncoder() -> Encoder { + failure(.unsupportedContainer) + return StatementFailingEncoder(codingPath: codingPath, failure: failure) + } +} + +struct StatementFailingSingleValueEncodingContainer: SingleValueEncodingContainer { + var codingPath: [CodingKey] + let failure: (StatementCodingFailure) -> Void + + init(codingPath: [CodingKey], failure: @escaping (StatementCodingFailure) -> Void) { + self.codingPath = codingPath + self.failure = failure + } + + mutating func encodeNil() throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: T) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } +} + +final class StatementFailingEncoder: Encoder { + var codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] = [:] + let failure: (StatementCodingFailure) -> Void + + init(codingPath: [CodingKey], failure: @escaping (StatementCodingFailure) -> Void) { + self.codingPath = codingPath + self.failure = failure + } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + failure(.unsupportedContainer) + return KeyedEncodingContainer(StatementFailingKeyedEncodingContainer(codingPath: codingPath, failure: failure)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + failure(.unsupportedContainer) + return StatementFailingUnkeyedEncodingContainer(codingPath: codingPath, failure: failure) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + failure(.unsupportedContainer) + return StatementFailingSingleValueEncodingContainer(codingPath: codingPath, failure: failure) + } +} diff --git a/Sources/LSQLiteExtensions/Coding/Row/Statement+Row.swift b/Sources/LSQLiteExtensions/Coding/Row/Statement+Row.swift new file mode 100644 index 0000000..8b5dbde --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Row/Statement+Row.swift @@ -0,0 +1,29 @@ +import Foundation +import LSQLite + +extension Statement { + /// Decodes the current row into a value using column names as keys. + /// - Parameter type: Type to decode from the current row. + /// - Returns: The decoded value, or nil on mismatch or unsupported values. + /// + /// Only top-level keyed containers are supported. Coding keys must match the + /// result column names exactly. Supported value types are `nil`, `Data`, + /// `String`, `Int` (64-bit), and `Double`. + /// + /// Related SQLite: `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type` + public func row(_ type: Row.Type = Row.self) -> Row? { + do { + let decoder = try StatementRowDecoder(statement: self) + let value = try Row(from: decoder) + if decoder.failure != nil { + return nil + } + if decoder.decodedColumnCount != decoder.columnCount { + return nil + } + return value + } catch { + return nil + } + } +} diff --git a/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift b/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift new file mode 100644 index 0000000..df36f9d --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift @@ -0,0 +1,224 @@ +import Foundation +import LSQLite + +struct StatementColumnMap { + let map: [String: Int32] + let columnCount: Int +} + +func statementColumnMap(for statement: Statement) throws -> StatementColumnMap { + let count = Int(statement.columnCount) + var map: [String: Int32] = [:] + map.reserveCapacity(count) + if count > 0 { + for offset in 0.. = [] + + init(statement: Statement) throws { + let columnMap = try statementColumnMap(for: statement) + self.statement = statement + self.columnMap = columnMap.map + self.columnCount = columnMap.columnCount + } + + init(statement: Statement, columnMap: StatementColumnMap) { + self.statement = statement + self.columnMap = columnMap.map + self.columnCount = columnMap.columnCount + } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + return KeyedDecodingContainer(StatementRowContainer(decoder: self)) + } + + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + func singleValueContainer() throws -> SingleValueDecodingContainer { + fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + var decodedColumnCount: Int { + decodedIndices.count + } + + func fail(_ error: StatementCodingFailure) { + if failure == nil { + failure = error + } + } +} + +struct StatementRowContainer: KeyedDecodingContainerProtocol { + var codingPath: [CodingKey] { decoder.codingPath } + let decoder: StatementRowDecoder + + init(decoder: StatementRowDecoder) { + self.decoder = decoder + } + + var allKeys: [Key] { + decoder.columnMap.keys.compactMap { Key(stringValue: $0) } + } + + func contains(_ key: Key) -> Bool { + decoder.columnMap[key.stringValue] != nil + } + + func decodeNil(forKey key: Key) throws -> Bool { + let index = try columnIndex(for: key) + return decoder.statement.columnType(at: index) == .null + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + try unsupportedValue() + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + let index = try nonNullColumnIndex(for: key) + guard let value = decoder.statement.columnText(at: index) else { + decoder.fail(.typeMismatch) + throw StatementCodingFailure.typeMismatch + } + return value + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + let index = try nonNullColumnIndex(for: key) + return decoder.statement.columnDouble(at: index) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + try unsupportedValue() + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + let index = try nonNullColumnIndex(for: key) + let value = decoder.statement.columnInt64(at: index) + guard let intValue = Int(exactly: value) else { + decoder.fail(.typeMismatch) + throw StatementCodingFailure.typeMismatch + } + return intValue + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + try unsupportedValue() + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + try unsupportedValue() + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + try unsupportedValue() + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + try unsupportedValue() + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + try unsupportedValue() + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + try unsupportedValue() + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + try unsupportedValue() + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + try unsupportedValue() + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + try unsupportedValue() + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + if type == Data.self { + let index = try nonNullColumnIndex(for: key) + let length = Int(decoder.statement.columnBytes(at: index)) + if length == 0 { + return Data() as! T + } + guard let blob = decoder.statement.columnBlob(at: index) else { + decoder.fail(.typeMismatch) + throw StatementCodingFailure.typeMismatch + } + let data = Data(bytes: blob, count: length) + return data as! T + } + try unsupportedValue() + } + + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { + decoder.fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { + decoder.fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + func superDecoder() throws -> Decoder { + decoder.fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + func superDecoder(forKey key: Key) throws -> Decoder { + decoder.fail(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + func columnIndex(for key: Key) throws -> Int32 { + let name = key.stringValue + guard let index = decoder.columnMap[name] else { + decoder.fail(.missingColumn) + throw StatementCodingFailure.missingColumn + } + decoder.decodedIndices.insert(index) + return index + } + + func nonNullColumnIndex(for key: Key) throws -> Int32 { + let index = try columnIndex(for: key) + guard decoder.statement.columnType(at: index) != .null else { + decoder.fail(.typeMismatch) + throw StatementCodingFailure.typeMismatch + } + return index + } + + func unsupportedValue() throws -> Never { + decoder.fail(.unsupportedValue) + throw StatementCodingFailure.unsupportedValue + } +} diff --git a/Sources/LSQLiteExtensions/Coding/StatementCodingFailure.swift b/Sources/LSQLiteExtensions/Coding/StatementCodingFailure.swift new file mode 100644 index 0000000..bce1f53 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/StatementCodingFailure.swift @@ -0,0 +1,10 @@ +enum StatementCodingFailure: Error { + case invalidParameter + case duplicateParameter + case unsupportedValue + case unsupportedContainer + case invalidColumn + case duplicateColumn + case missingColumn + case typeMismatch +} diff --git a/Sources/LSQLiteExtensions/Connection+Migration.swift b/Sources/LSQLiteExtensions/Connection+Migration.swift new file mode 100644 index 0000000..2a8e4bb --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Migration.swift @@ -0,0 +1,71 @@ +import LSQLite + +extension Connection { + /// A migration step tied to a target version value. + /// + /// Related SQLite: `PRAGMA user_version` + @frozen public struct Migration { + public let version: Int32 + public let body: (Connection) -> ResultCode + + /// Creates a migration step. + /// - Parameters: + /// - version: Target version for the migration. + /// - body: Work to run for the migration. + /// + /// Related SQLite: `PRAGMA user_version` + @inlinable public init(version: Int32, body: @escaping (Connection) -> ResultCode) { + self.version = version + self.body = body + } + } + + /// Applies migrations in ascending version order and updates the stored version value. + /// - Parameters: + /// - migrations: Migrations to apply. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from the first failure, or `.done` when all migrations complete. + /// + /// Returns `.misuse` when duplicate versions are supplied or when the current version cannot be read. + /// + /// Related SQLite: `PRAGMA user_version`, `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `ROLLBACK TO` + public func migrate(_ migrations: [Migration], databaseName: String? = nil) -> ResultCode { + guard !migrations.isEmpty else { + return .done + } + + var seenVersions: Set = [] + seenVersions.reserveCapacity(migrations.count) + for migration in migrations { + if seenVersions.contains(migration.version) { + return .misuse + } + seenVersions.insert(migration.version) + } + + let currentVersion = userVersion(databaseName: databaseName) + guard currentVersion.0 == .done else { + return currentVersion.0 + } + guard var version = currentVersion.1 else { + return .misuse + } + + let ordered = migrations.sorted { $0.version < $1.version } + for migration in ordered where migration.version > version { + let result = transaction { connection in + let bodyResult = migration.body(connection) + guard bodyResult == .ok || bodyResult == .done else { + return bodyResult + } + return connection.setUserVersion(migration.version, databaseName: databaseName) + } + guard result == .ok || result == .done else { + return result + } + version = migration.version + } + + return .done + } +} diff --git a/Sources/LSQLiteExtensions/Connection+Pragma.swift b/Sources/LSQLiteExtensions/Connection+Pragma.swift new file mode 100644 index 0000000..3019814 --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Pragma.swift @@ -0,0 +1,885 @@ +import LSQLite + +extension Connection { + /// Modes that control the rollback journal behavior. + /// + /// Related SQLite: `PRAGMA journal_mode` + @frozen public struct JournalMode: Hashable, RawRepresentable { + public let rawValue: String + + @inlinable public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Use rollback journaling with delete-on-commit behavior. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let delete = Self(rawValue: "delete") + + /// Use rollback journaling and truncate the journal at commit. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let truncate = Self(rawValue: "truncate") + + /// Use rollback journaling with persistent journal files. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let persist = Self(rawValue: "persist") + + /// Use in-memory rollback journaling. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let memory = Self(rawValue: "memory") + + /// Use write-ahead logging. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let wal = Self(rawValue: "wal") + + /// Disable rollback journaling. + /// + /// Related SQLite: `PRAGMA journal_mode` + public static let off = Self(rawValue: "off") + } + + /// Synchronization settings for commits and checkpoints. + /// + /// Related SQLite: `PRAGMA synchronous` + @frozen public struct SynchronousMode: Hashable, RawRepresentable { + public let rawValue: Int32 + + @inlinable public init(rawValue: Int32) { + self.rawValue = rawValue + } + + /// No synchronization beyond OS defaults. + /// + /// Related SQLite: `PRAGMA synchronous` + public static let off = Self(rawValue: 0) + + /// Full synchronization for device buffers. + /// + /// Related SQLite: `PRAGMA synchronous` + public static let normal = Self(rawValue: 1) + + /// Full synchronization for device and filesystem buffers. + /// + /// Related SQLite: `PRAGMA synchronous` + public static let full = Self(rawValue: 2) + + /// Extra synchronization between device and filesystem buffers. + /// + /// Related SQLite: `PRAGMA synchronous` + public static let extra = Self(rawValue: 3) + } + + /// Temporary storage modes. + /// + /// Related SQLite: `PRAGMA temp_store` + @frozen public struct TempStore: Hashable, RawRepresentable { + public let rawValue: Int32 + + @inlinable public init(rawValue: Int32) { + self.rawValue = rawValue + } + + /// Use the default storage mode. + /// + /// Related SQLite: `PRAGMA temp_store` + public static let `default` = Self(rawValue: 0) + + /// Store temporary tables and indices in files. + /// + /// Related SQLite: `PRAGMA temp_store` + public static let file = Self(rawValue: 1) + + /// Store temporary tables and indices in memory. + /// + /// Related SQLite: `PRAGMA temp_store` + public static let memory = Self(rawValue: 2) + } + + /// Locking modes for the database connection. + /// + /// Related SQLite: `PRAGMA locking_mode` + @frozen public struct LockingMode: Hashable, RawRepresentable { + public let rawValue: String + + @inlinable public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Allow shared read/write access. + /// + /// Related SQLite: `PRAGMA locking_mode` + public static let normal = Self(rawValue: "normal") + + /// Keep the database file locked for the lifetime of the connection. + /// + /// Related SQLite: `PRAGMA locking_mode` + public static let exclusive = Self(rawValue: "exclusive") + } + + /// Automatic vacuum configuration. + /// + /// Related SQLite: `PRAGMA auto_vacuum` + @frozen public struct AutoVacuumMode: Hashable, RawRepresentable { + public let rawValue: Int32 + + @inlinable public init(rawValue: Int32) { + self.rawValue = rawValue + } + + /// No automatic vacuuming. + /// + /// Related SQLite: `PRAGMA auto_vacuum` + public static let none = Self(rawValue: 0) + + /// Full auto-vacuum mode. + /// + /// Related SQLite: `PRAGMA auto_vacuum` + public static let full = Self(rawValue: 1) + + /// Incremental auto-vacuum mode. + /// + /// Related SQLite: `PRAGMA auto_vacuum` + public static let incremental = Self(rawValue: 2) + } + + /// Row describing a table column. + /// + /// Related SQLite: `PRAGMA table_info` + @frozen public struct TableInfo: Hashable, Decodable { + public let columnId: Int + public let name: String + public let declaredType: String + public let notNull: Int + public let defaultValue: String? + public let primaryKey: Int + + private enum CodingKeys: String, CodingKey { + case columnId = "cid" + case name + case declaredType = "type" + case notNull = "notnull" + case defaultValue = "dflt_value" + case primaryKey = "pk" + } + } + + /// Row describing an index on a table. + /// + /// Related SQLite: `PRAGMA index_list` + @frozen public struct IndexListEntry: Hashable, Decodable { + public let sequence: Int + public let name: String + public let isUnique: Int + public let origin: String + public let isPartial: Int + + private enum CodingKeys: String, CodingKey { + case sequence = "seq" + case name + case isUnique = "unique" + case origin + case isPartial = "partial" + } + } + + /// Row describing columns in an index. + /// + /// Related SQLite: `PRAGMA index_info` + @frozen public struct IndexInfo: Hashable, Decodable { + public let sequence: Int + public let columnId: Int + public let name: String? + + private enum CodingKeys: String, CodingKey { + case sequence = "seqno" + case columnId = "cid" + case name + } + } + + /// Row describing a foreign key constraint. + /// + /// Related SQLite: `PRAGMA foreign_key_list` + @frozen public struct ForeignKeyListEntry: Hashable, Decodable { + public let id: Int + public let sequence: Int + public let tableName: String + public let fromColumn: String + public let toColumn: String + public let onUpdate: String + public let onDelete: String + public let match: String + + private enum CodingKeys: String, CodingKey { + case id + case sequence = "seq" + case tableName = "table" + case fromColumn = "from" + case toColumn = "to" + case onUpdate = "on_update" + case onDelete = "on_delete" + case match + } + } + + /// Reads the current journal mode. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not text. + /// + /// Related SQLite: `PRAGMA journal_mode`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func journalMode(databaseName: String? = nil) -> (ResultCode, JournalMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).journal_mode" + } else { + sql = "PRAGMA journal_mode" + } + let result = pragmaText(sql) + return (result.0, result.1.map { JournalMode(rawValue: $0.lowercased()) }) + } + + /// Sets the journal mode. + /// - Parameters: + /// - mode: Mode to request. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the resulting mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not text. + /// + /// Related SQLite: `PRAGMA journal_mode`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func setJournalMode(_ mode: JournalMode, databaseName: String? = nil) -> (ResultCode, JournalMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).journal_mode = \(mode.rawValue)" + } else { + sql = "PRAGMA journal_mode = \(mode.rawValue)" + } + let result = pragmaText(sql) + return (result.0, result.1.map { JournalMode(rawValue: $0.lowercased()) }) + } + + /// Reads the current synchronization mode. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA synchronous`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func synchronousMode(databaseName: String? = nil) -> (ResultCode, SynchronousMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).synchronous" + } else { + sql = "PRAGMA synchronous" + } + let result = pragmaInt32(sql) + return (result.0, result.1.map { SynchronousMode(rawValue: $0) }) + } + + /// Sets the synchronization mode. + /// - Parameters: + /// - mode: Mode to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA synchronous`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setSynchronousMode(_ mode: SynchronousMode, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).synchronous = \(mode.rawValue)" + } else { + sql = "PRAGMA synchronous = \(mode.rawValue)" + } + return pragmaNoRow(sql) + } + + /// Reads whether foreign key enforcement is enabled. + /// - Returns: A result code and the current enabled state when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA foreign_keys`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func foreignKeysEnabled() -> (ResultCode, Bool?) { + let result = pragmaInt32("PRAGMA foreign_keys") + return (result.0, result.1.map { $0 != 0 }) + } + + /// Enables or disables foreign key enforcement. + /// - Parameter enabled: Whether to enable enforcement. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA foreign_keys`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setForeignKeysEnabled(_ enabled: Bool) -> ResultCode { + let value = enabled ? 1 : 0 + return pragmaNoRow("PRAGMA foreign_keys = \(value)") + } + + /// Reads the busy timeout in milliseconds. + /// - Returns: A result code and the current timeout when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA busy_timeout`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func busyTimeout() -> (ResultCode, Int32?) { + pragmaInt32("PRAGMA busy_timeout") + } + + /// Sets the busy timeout in milliseconds. + /// - Parameter milliseconds: Timeout to store. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA busy_timeout`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func setBusyTimeout(_ milliseconds: Int32) -> ResultCode { + pragmaInt32("PRAGMA busy_timeout = \(milliseconds)").0 + } + + /// Reads the temporary storage mode. + /// - Returns: A result code and the current mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA temp_store`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func tempStore() -> (ResultCode, TempStore?) { + let result = pragmaInt32("PRAGMA temp_store") + return (result.0, result.1.map { TempStore(rawValue: $0) }) + } + + /// Sets the temporary storage mode. + /// - Parameter mode: Mode to store. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA temp_store`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setTempStore(_ mode: TempStore) -> ResultCode { + pragmaNoRow("PRAGMA temp_store = \(mode.rawValue)") + } + + /// Reads the cache size in pages. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current cache size when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA cache_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func cacheSize(databaseName: String? = nil) -> (ResultCode, Int32?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).cache_size" + } else { + sql = "PRAGMA cache_size" + } + return pragmaInt32(sql) + } + + /// Sets the cache size in pages. + /// - Parameters: + /// - value: Cache size to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA cache_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setCacheSize(_ value: Int32, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).cache_size = \(value)" + } else { + sql = "PRAGMA cache_size = \(value)" + } + return pragmaNoRow(sql) + } + + /// Reads the maximum memory mapping size in bytes. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current size when available. + /// + /// Returns `.misuse` when more than one row is produced or when the value is not an integer. + /// Returns `.done` with a nil value when the pragma produces no rows. + /// + /// Related SQLite: `PRAGMA mmap_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int64`, `sqlite3_column_type`, `sqlite3_finalize` + public func mmapSize(databaseName: String? = nil) -> (ResultCode, Int64?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).mmap_size" + } else { + sql = "PRAGMA mmap_size" + } + return pragmaInt64(sql, allowNoRow: true) + } + + /// Sets the maximum memory mapping size in bytes. + /// - Parameters: + /// - value: Size to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA mmap_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setMmapSize(_ value: Int64, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).mmap_size = \(value)" + } else { + sql = "PRAGMA mmap_size = \(value)" + } + return pragmaNoRow(sql) + } + + /// Reads the locking mode for the database. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not text. + /// + /// Related SQLite: `PRAGMA locking_mode`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func lockingMode(databaseName: String? = nil) -> (ResultCode, LockingMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).locking_mode" + } else { + sql = "PRAGMA locking_mode" + } + let result = pragmaText(sql) + return (result.0, result.1.map { LockingMode(rawValue: $0.lowercased()) }) + } + + /// Sets the locking mode for the database. + /// - Parameters: + /// - mode: Mode to request. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the resulting mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not text. + /// + /// Related SQLite: `PRAGMA locking_mode`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func setLockingMode(_ mode: LockingMode, databaseName: String? = nil) -> (ResultCode, LockingMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).locking_mode = \(mode.rawValue)" + } else { + sql = "PRAGMA locking_mode = \(mode.rawValue)" + } + let result = pragmaText(sql) + return (result.0, result.1.map { LockingMode(rawValue: $0.lowercased()) }) + } + + /// Reads the user version value. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current value when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA user_version`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func userVersion(databaseName: String? = nil) -> (ResultCode, Int32?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).user_version" + } else { + sql = "PRAGMA user_version" + } + return pragmaInt32(sql) + } + + /// Sets the user version value. + /// - Parameters: + /// - value: Value to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA user_version`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setUserVersion(_ value: Int32, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).user_version = \(value)" + } else { + sql = "PRAGMA user_version = \(value)" + } + return pragmaNoRow(sql) + } + + /// Reads the application identifier value. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current value when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA application_id`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func applicationId(databaseName: String? = nil) -> (ResultCode, Int32?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).application_id" + } else { + sql = "PRAGMA application_id" + } + return pragmaInt32(sql) + } + + /// Sets the application identifier value. + /// - Parameters: + /// - value: Value to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA application_id`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setApplicationId(_ value: Int32, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).application_id = \(value)" + } else { + sql = "PRAGMA application_id = \(value)" + } + return pragmaNoRow(sql) + } + + /// Runs a full integrity check. + /// - Parameters: + /// - maximumErrors: Optional maximum number of errors to report. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and reported messages. + /// + /// Related SQLite: `PRAGMA integrity_check`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func integrityCheck(maximumErrors: Int32? = nil, databaseName: String? = nil) -> (ResultCode, [String]) { + let sql: String + if let databaseName { + if let maximumErrors { + sql = "PRAGMA \(quotedIdentifier(databaseName)).integrity_check(\(maximumErrors))" + } else { + sql = "PRAGMA \(quotedIdentifier(databaseName)).integrity_check" + } + } else if let maximumErrors { + sql = "PRAGMA integrity_check(\(maximumErrors))" + } else { + sql = "PRAGMA integrity_check" + } + return pragmaTextList(sql) + } + + /// Runs a quick integrity check. + /// - Parameters: + /// - maximumErrors: Optional maximum number of errors to report. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and reported messages. + /// + /// Related SQLite: `PRAGMA quick_check`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func quickCheck(maximumErrors: Int32? = nil, databaseName: String? = nil) -> (ResultCode, [String]) { + let sql: String + if let databaseName { + if let maximumErrors { + sql = "PRAGMA \(quotedIdentifier(databaseName)).quick_check(\(maximumErrors))" + } else { + sql = "PRAGMA \(quotedIdentifier(databaseName)).quick_check" + } + } else if let maximumErrors { + sql = "PRAGMA quick_check(\(maximumErrors))" + } else { + sql = "PRAGMA quick_check" + } + return pragmaTextList(sql) + } + + /// Reads the auto-vacuum mode. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current mode when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA auto_vacuum`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func autoVacuum(databaseName: String? = nil) -> (ResultCode, AutoVacuumMode?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).auto_vacuum" + } else { + sql = "PRAGMA auto_vacuum" + } + let result = pragmaInt32(sql) + return (result.0, result.1.map { AutoVacuumMode(rawValue: $0) }) + } + + /// Sets the auto-vacuum mode. + /// - Parameters: + /// - mode: Mode to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA auto_vacuum`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setAutoVacuum(_ mode: AutoVacuumMode, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).auto_vacuum = \(mode.rawValue)" + } else { + sql = "PRAGMA auto_vacuum = \(mode.rawValue)" + } + return pragmaNoRow(sql) + } + + /// Runs an incremental vacuum operation. + /// - Parameters: + /// - pageCount: Optional number of pages to vacuum. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA incremental_vacuum`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func incrementalVacuum(pageCount: Int32? = nil, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + if let pageCount { + sql = "PRAGMA \(quotedIdentifier(databaseName)).incremental_vacuum(\(pageCount))" + } else { + sql = "PRAGMA \(quotedIdentifier(databaseName)).incremental_vacuum" + } + } else if let pageCount { + sql = "PRAGMA incremental_vacuum(\(pageCount))" + } else { + sql = "PRAGMA incremental_vacuum" + } + return pragmaNoRow(sql) + } + + /// Reads table column metadata. + /// - Parameters: + /// - tableName: Table name to inspect. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `PRAGMA table_info`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func tableInfo(_ tableName: String, databaseName: String? = nil) -> (ResultCode, [TableInfo]) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).table_info(\(quotedIdentifier(tableName)))" + } else { + sql = "PRAGMA table_info(\(quotedIdentifier(tableName)))" + } + return query(sql, rows: TableInfo.self) + } + + /// Reads index metadata for a table. + /// - Parameters: + /// - tableName: Table name to inspect. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `PRAGMA index_list`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func indexList(_ tableName: String, databaseName: String? = nil) -> (ResultCode, [IndexListEntry]) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).index_list(\(quotedIdentifier(tableName)))" + } else { + sql = "PRAGMA index_list(\(quotedIdentifier(tableName)))" + } + return query(sql, rows: IndexListEntry.self) + } + + /// Reads index column metadata. + /// - Parameters: + /// - indexName: Index name to inspect. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `PRAGMA index_info`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func indexInfo(_ indexName: String, databaseName: String? = nil) -> (ResultCode, [IndexInfo]) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).index_info(\(quotedIdentifier(indexName)))" + } else { + sql = "PRAGMA index_info(\(quotedIdentifier(indexName)))" + } + return query(sql, rows: IndexInfo.self) + } + + /// Reads foreign key metadata for a table. + /// - Parameters: + /// - tableName: Table name to inspect. + /// - databaseName: Optional database name qualifier. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `PRAGMA foreign_key_list`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func foreignKeyList(_ tableName: String, databaseName: String? = nil) -> (ResultCode, [ForeignKeyListEntry]) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).foreign_key_list(\(quotedIdentifier(tableName)))" + } else { + sql = "PRAGMA foreign_key_list(\(quotedIdentifier(tableName)))" + } + return query(sql, rows: ForeignKeyListEntry.self) + } + + /// Reads compile-time options used to build SQLite. + /// - Returns: A result code and the returned options. + /// + /// Related SQLite: `PRAGMA compile_options`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_text`, `sqlite3_column_type`, `sqlite3_finalize` + public func compileOptions() -> (ResultCode, [String]) { + pragmaTextList("PRAGMA compile_options") + } + + /// Reads the database page size in bytes. + /// - Parameter databaseName: Optional database name qualifier. + /// - Returns: A result code and the current page size when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA page_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func pageSize(databaseName: String? = nil) -> (ResultCode, Int32?) { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).page_size" + } else { + sql = "PRAGMA page_size" + } + return pragmaInt32(sql) + } + + /// Sets the database page size in bytes. + /// - Parameters: + /// - value: Page size to store. + /// - databaseName: Optional database name qualifier. + /// - Returns: Result code from stepping the statement. + /// + /// Returns `.misuse` when a row is produced. + /// + /// Related SQLite: `PRAGMA page_size`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_finalize` + public func setPageSize(_ value: Int32, databaseName: String? = nil) -> ResultCode { + let sql: String + if let databaseName { + sql = "PRAGMA \(quotedIdentifier(databaseName)).page_size = \(value)" + } else { + sql = "PRAGMA page_size = \(value)" + } + return pragmaNoRow(sql) + } +} + +private extension Connection { + func pragmaNoRow(_ sql: String) -> ResultCode { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return prepareResult + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return prepareResult + } + let stepResult = prepared.step() + guard stepResult != .row else { + return .misuse + } + return stepResult + } + + func pragmaInt32(_ sql: String) -> (ResultCode, Int32?) { + pragmaScalar(sql) { statement in + guard statement.columnType(at: 0) == .integer else { + return nil + } + return statement.columnInt(at: 0) + } + } + + func pragmaInt64(_ sql: String, allowNoRow: Bool = false) -> (ResultCode, Int64?) { + pragmaScalar(sql, allowNoRow: allowNoRow) { statement in + guard statement.columnType(at: 0) == .integer else { + return nil + } + return statement.columnInt64(at: 0) + } + } + + func pragmaText(_ sql: String) -> (ResultCode, String?) { + pragmaScalar(sql) { statement in + guard statement.columnType(at: 0) == .text else { + return nil + } + return statement.columnText(at: 0) + } + } + + func pragmaTextList(_ sql: String) -> (ResultCode, [String]) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, []) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, []) + } + var values: [String] = [] + while true { + let stepResult = prepared.step() + switch stepResult { + case .row: + guard prepared.columnType(at: 0) == .text else { + return (.misuse, []) + } + guard let text = prepared.columnText(at: 0) else { + return (.misuse, []) + } + values.append(text) + case .done: + return (.done, values) + default: + return (stepResult, []) + } + } + } + + func pragmaScalar(_ sql: String, allowNoRow: Bool = false, decode: (Statement) -> T?) -> (ResultCode, T?) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, nil) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, nil) + } + let firstStep = prepared.step() + switch firstStep { + case .row: + guard let value = decode(prepared) else { + return (.misuse, nil) + } + let secondStep = prepared.step() + switch secondStep { + case .done: + return (.done, value) + case .row: + return (.misuse, nil) + default: + return (secondStep, nil) + } + case .done: + return allowNoRow ? (.done, nil) : (.misuse, nil) + default: + return (firstStep, nil) + } + } +} + +private func quotedIdentifier(_ rawValue: String) -> String { + let escaped = rawValue.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" +} diff --git a/Sources/LSQLiteExtensions/Connection+Query.swift b/Sources/LSQLiteExtensions/Connection+Query.swift new file mode 100644 index 0000000..b4fdd1c --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Query.swift @@ -0,0 +1,139 @@ +import LSQLite + +extension Connection { + /// Prepares and evaluates the first statement in the SQL string once. + /// - Parameter sql: SQL text to prepare. + /// - Returns: Result code from preparing or stepping the statement, or `.misuse` when a row is produced. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_finalize` + public func query(_ sql: String) -> ResultCode { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return prepareResult + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return prepareResult + } + return prepared.query() + } + + /// Prepares, binds, and evaluates the first statement in the SQL string once. + /// - Parameters: + /// - sql: SQL text to prepare. + /// - binding: Value to encode and bind. + /// - Returns: Result code from preparing or stepping the statement, or `.misuse` when binding fails or a row is produced. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings`, `sqlite3_finalize` + public func query(_ sql: String, binding: Binding) -> ResultCode { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return prepareResult + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return prepareResult + } + return prepared.query(binding) + } + + /// Prepares, evaluates, and decodes at most one row from the first statement in the SQL string. + /// - Parameters: + /// - sql: SQL text to prepare. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded row when present. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// Returns `.misuse` when decoding fails or when more than one row is produced. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_finalize` + public func query(_ sql: String, row type: Row.Type = Row.self) -> (ResultCode, Row?) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, nil) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, nil) + } + return prepared.query(row: type) + } + + /// Prepares, evaluates, and decodes all rows from the first statement in the SQL string. + /// - Parameters: + /// - sql: SQL text to prepare. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded rows. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// Returns `.misuse` when decoding fails. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_finalize` + public func query(_ sql: String, rows type: Row.Type = Row.self) -> (ResultCode, [Row]) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, []) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, []) + } + return prepared.query(rows: type) + } + + /// Prepares, binds, evaluates, and decodes at most one row from the first statement in the SQL string. + /// - Parameters: + /// - sql: SQL text to prepare. + /// - binding: Value to encode and bind. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded row when present. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// Returns `.misuse` when binding or decoding fails, or when more than one row is produced. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings`, `sqlite3_finalize` + public func query(_ sql: String, binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, nil) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, nil) + } + return prepared.query(binding, row: type) + } + + /// Prepares, binds, evaluates, and decodes all rows from the first statement in the SQL string. + /// - Parameters: + /// - sql: SQL text to prepare. + /// - binding: Value to encode and bind. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded rows. + /// + /// Only the first statement is executed; remaining SQL text is ignored. The prepared statement is finalized before returning. + /// Returns `.misuse` when binding or decoding fails. + /// + /// Related SQLite: `sqlite3_prepare_v2`, `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings`, `sqlite3_finalize` + public func query(_ sql: String, binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row]) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, []) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, []) + } + return prepared.query(binding, rows: type) + } +} diff --git a/Sources/LSQLiteExtensions/Connection+Schema.swift b/Sources/LSQLiteExtensions/Connection+Schema.swift new file mode 100644 index 0000000..82f207c --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Schema.swift @@ -0,0 +1,198 @@ +import LSQLite + +extension Connection { + /// Schema tables that store object definitions. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + @frozen public struct SchemaTable: Hashable, RawRepresentable { + public let rawValue: String + + @inlinable public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Schema table used for the primary schema storage. + /// + /// Related SQLite: `sqlite_schema` + public static let schema = Self(rawValue: "sqlite_schema") + + /// Legacy schema table used for the primary schema storage. + /// + /// Related SQLite: `sqlite_master` + public static let master = Self(rawValue: "sqlite_master") + } + + /// Schema object type values stored in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + @frozen public struct SchemaObjectType: Hashable, RawRepresentable { + public let rawValue: String + + @inlinable public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Table entries stored in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + public static let table = Self(rawValue: "table") + + /// View entries stored in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + public static let view = Self(rawValue: "view") + + /// Index entries stored in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + public static let index = Self(rawValue: "index") + + /// Trigger entries stored in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + public static let trigger = Self(rawValue: "trigger") + } + + /// Row describing an entry in the schema table. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master` + @frozen public struct SchemaObject: Hashable, Decodable { + /// The object type stored in the schema table. + public let type: String + /// The object name stored in the schema table. + public let name: String + /// The table name associated with the object. + public let tableName: String + /// The root page number assigned to the object. + public let rootPage: Int + /// The SQL used to create the object when available. + public let sql: String? + + private enum CodingKeys: String, CodingKey { + case type + case name + case tableName = "tbl_name" + case rootPage = "rootpage" + case sql + } + } + + /// Reads schema entries from the selected schema table. + /// - Parameters: + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func schemaObjects(in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = true) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: [], in: table, databaseName: databaseName, includeInternal: includeInternal) + } + + /// Reads schema entries for a specific type from the selected schema table. + /// - Parameters: + /// - type: Object type to include. + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func schemaObjects(of type: SchemaObjectType, in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: [type], in: table, databaseName: databaseName, includeInternal: includeInternal) + } + + /// Reads schema entries for specific types from the selected schema table. + /// - Parameters: + /// - types: Object types to include. + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func schemaObjects(of types: [SchemaObjectType], in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + let sql = schemaSelectSQL(table: table, databaseName: databaseName, types: types, includeInternal: includeInternal) + return query(sql, rows: SchemaObject.self) + } + + /// Reads table entries from the schema table. + /// - Parameters: + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func tables(in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: .table, in: table, databaseName: databaseName, includeInternal: includeInternal) + } + + /// Reads view entries from the schema table. + /// - Parameters: + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func views(in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: .view, in: table, databaseName: databaseName, includeInternal: includeInternal) + } + + /// Reads index entries from the schema table. + /// - Parameters: + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func indexes(in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: .index, in: table, databaseName: databaseName, includeInternal: includeInternal) + } + + /// Reads trigger entries from the schema table. + /// - Parameters: + /// - table: Schema table to read. + /// - databaseName: Optional database name qualifier. + /// - includeInternal: Whether to include internal entries. + /// - Returns: A result code and the returned rows. + /// + /// Related SQLite: `sqlite_schema`, `sqlite_master`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_name`, `sqlite3_column_int64`, `sqlite3_column_text`, `sqlite3_finalize` + public func triggers(in table: SchemaTable = .master, databaseName: String? = nil, includeInternal: Bool = false) -> (ResultCode, [SchemaObject]) { + schemaObjects(of: .trigger, in: table, databaseName: databaseName, includeInternal: includeInternal) + } +} + +private func schemaSelectSQL(table: Connection.SchemaTable, databaseName: String?, types: [Connection.SchemaObjectType], includeInternal: Bool) -> String { + let qualifiedTable: String + if let databaseName { + qualifiedTable = "\(quotedIdentifier(databaseName)).\(quotedIdentifier(table.rawValue))" + } else { + qualifiedTable = quotedIdentifier(table.rawValue) + } + var sql = "SELECT type, name, tbl_name, rootpage, sql FROM \(qualifiedTable)" + var filters: [String] = [] + if !types.isEmpty { + let list = types.map { quotedLiteral($0.rawValue) }.joined(separator: ", ") + filters.append("type IN (\(list))") + } + if !includeInternal { + filters.append("name NOT LIKE 'sqlite_%'") + } + if !filters.isEmpty { + sql.append(" WHERE \(filters.joined(separator: " AND "))") + } + return sql +} + +private func quotedIdentifier(_ rawValue: String) -> String { + let escaped = rawValue.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" +} + +private func quotedLiteral(_ rawValue: String) -> String { + let escaped = rawValue.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" +} diff --git a/Sources/LSQLiteExtensions/Connection+Transaction.swift b/Sources/LSQLiteExtensions/Connection+Transaction.swift new file mode 100644 index 0000000..db03dde --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Transaction.swift @@ -0,0 +1,146 @@ +import LSQLite + +extension Connection { + /// Modes that influence how a new transaction acquires its locks. + /// + /// Related SQLite: `BEGIN`, `DEFERRED`, `IMMEDIATE`, `EXCLUSIVE` + @frozen public enum TransactionMode: String { + /// Uses the default lock acquisition behavior. + /// + /// Related SQLite: `DEFERRED` + case deferred = "DEFERRED" + + /// Requests the write lock earlier in the transaction. + /// + /// Related SQLite: `IMMEDIATE` + case immediate = "IMMEDIATE" + + /// Requests the most restrictive lock up front. + /// + /// Related SQLite: `EXCLUSIVE` + case exclusive = "EXCLUSIVE" + } + + /// A name identifying a nested transaction scope. + /// + /// Related SQLite: `SAVEPOINT`, `RELEASE`, `ROLLBACK TO` + @frozen public struct SavepointName: Hashable, RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + @inlinable public init(rawValue: String) { + self.rawValue = rawValue + } + + @inlinable public init(stringLiteral value: String) { + self.rawValue = value + } + } + + /// Starts a new transaction on the connection. + /// - Parameter mode: Mode used to start the transaction. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `BEGIN`, `DEFERRED`, `IMMEDIATE`, `EXCLUSIVE` + public func beginTransaction(_ mode: TransactionMode = .deferred) -> ResultCode { + exec(mode.beginSQL) + } + + /// Finalizes the active transaction on the connection. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `COMMIT` + public func commitTransaction() -> ResultCode { + exec("COMMIT") + } + + /// Reverts the active transaction on the connection. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `ROLLBACK` + public func rollbackTransaction() -> ResultCode { + exec("ROLLBACK") + } + + /// Creates a named nested transaction scope. + /// - Parameter name: Name identifying the nested scope. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `SAVEPOINT` + public func savepoint(_ name: SavepointName) -> ResultCode { + exec("SAVEPOINT \(quotedIdentifier(name.rawValue))") + } + + /// Finishes a named nested transaction scope. + /// - Parameter name: Name identifying the nested scope. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `RELEASE` + public func releaseSavepoint(_ name: SavepointName) -> ResultCode { + exec("RELEASE \(quotedIdentifier(name.rawValue))") + } + + /// Reverts work in a named nested transaction scope. + /// - Parameter name: Name identifying the nested scope. + /// - Returns: Result code from the operation. + /// + /// Related SQLite: `ROLLBACK TO` + public func rollbackToSavepoint(_ name: SavepointName) -> ResultCode { + exec("ROLLBACK TO \(quotedIdentifier(name.rawValue))") + } + + /// Runs a body within a transaction, using a nested scope if one is already active. + /// - Parameters: + /// - mode: Mode used when starting a new outer transaction. + /// - body: Work to execute. Return `.ok` or `.done` to finalize changes; any other value reverts them. + /// - Returns: The body result when finalization succeeds, otherwise the finalization result code. + /// + /// Related SQLite: `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `ROLLBACK TO` + public func transaction(_ mode: TransactionMode = .deferred, _ body: (Connection) -> ResultCode) -> ResultCode { + if isAutocommit { + let beginResult = beginTransaction(mode) + guard beginResult == .ok else { + return beginResult + } + let bodyResult = body(self) + if bodyResult == .ok || bodyResult == .done { + let commitResult = commitTransaction() + return commitResult == .ok ? bodyResult : commitResult + } + let rollbackResult = rollbackTransaction() + return rollbackResult == .ok ? bodyResult : rollbackResult + } + + let savepointName = makeSavepointName() + let savepointResult = savepoint(savepointName) + guard savepointResult == .ok else { + return savepointResult + } + let bodyResult = body(self) + if bodyResult == .ok || bodyResult == .done { + let releaseResult = releaseSavepoint(savepointName) + return releaseResult == .ok ? bodyResult : releaseResult + } + let rollbackResult = rollbackToSavepoint(savepointName) + guard rollbackResult == .ok else { + return rollbackResult + } + let releaseResult = releaseSavepoint(savepointName) + return releaseResult == .ok ? bodyResult : releaseResult + } +} + +private extension Connection.TransactionMode { + var beginSQL: String { + "BEGIN \(rawValue)" + } +} + +private func quotedIdentifier(_ rawValue: String) -> String { + let escaped = rawValue.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" +} + +private func makeSavepointName() -> Connection.SavepointName { + let value = UInt64.random(in: UInt64.min...UInt64.max) + return Connection.SavepointName(rawValue: "lsqlite_txn_\(value)") +} diff --git a/Sources/LSQLiteExtensions/Connection+WAL.swift b/Sources/LSQLiteExtensions/Connection+WAL.swift new file mode 100644 index 0000000..0b08097 --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+WAL.swift @@ -0,0 +1,78 @@ +import LSQLite + +extension Connection { + /// Counts reported by a checkpoint operation. + /// + /// Related SQLite: `sqlite3_wal_checkpoint_v2` + @frozen public struct WALCheckpointInfo: Hashable { + /// Total frames in the log at the time of the checkpoint. + public let frameCount: Int32 + /// Total frames checkpointed during the operation. + public let checkpointedFrameCount: Int32 + + @inlinable public init(frameCount: Int32, checkpointedFrameCount: Int32) { + self.frameCount = frameCount + self.checkpointedFrameCount = checkpointedFrameCount + } + } + + /// Reads the configured automatic checkpoint frame threshold. + /// - Returns: A result code and the current threshold when available. + /// + /// Returns `.misuse` when no row is produced, when more than one row is produced, or when the value is not an integer. + /// + /// Related SQLite: `PRAGMA wal_autocheckpoint`, `sqlite3_prepare_v2`, `sqlite3_step`, `sqlite3_column_int`, `sqlite3_column_type`, `sqlite3_finalize` + public func autoWALCheckpoint() -> (ResultCode, Int32?) { + walPragmaInt32("PRAGMA wal_autocheckpoint") + } + + /// Runs a checkpoint and returns the frame counts. + /// - Parameters: + /// - databaseName: Optional database name qualifier. + /// - mode: Checkpoint mode to use. + /// - Returns: A result code and the frame counts. + /// + /// Related SQLite: `sqlite3_wal_checkpoint_v2`, `SQLITE_CHECKPOINT_*` + public func walCheckpoint(_ databaseName: String? = nil, mode: CheckpointMode = .passive) -> (ResultCode, WALCheckpointInfo) { + let target = databaseName ?? "main" + var frameCount: Int32 = -1 + var checkpointedFrameCount: Int32 = -1 + let result = walCheckpoint(target, mode: mode, frameCount: &frameCount, totalFrameCount: &checkpointedFrameCount) + return (result, WALCheckpointInfo(frameCount: frameCount, checkpointedFrameCount: checkpointedFrameCount)) + } +} + +private extension Connection { + func walPragmaInt32(_ sql: String) -> (ResultCode, Int32?) { + var statement: Statement? + let prepareResult = Statement.prepare(&statement, sql: sql, for: self) + guard let prepared = statement else { + return (prepareResult, nil) + } + defer { _ = prepared.finalize() } + guard prepareResult == .ok else { + return (prepareResult, nil) + } + let firstStep = prepared.step() + switch firstStep { + case .row: + guard prepared.columnType(at: 0) == .integer else { + return (.misuse, nil) + } + let value = prepared.columnInt(at: 0) + let secondStep = prepared.step() + switch secondStep { + case .done: + return (.done, value) + case .row: + return (.misuse, nil) + default: + return (secondStep, nil) + } + case .done: + return (.misuse, nil) + default: + return (firstStep, nil) + } + } +} diff --git a/Sources/LSQLiteExtensions/Statement+Query.swift b/Sources/LSQLiteExtensions/Statement+Query.swift new file mode 100644 index 0000000..a5a425b --- /dev/null +++ b/Sources/LSQLiteExtensions/Statement+Query.swift @@ -0,0 +1,190 @@ +import LSQLite + +extension Statement { + /// Evaluates the prepared statement once and returns the result code. + /// - Returns: Result code from stepping the statement, or `.misuse` when a row is produced. + /// + /// The statement is reset before returning. + /// + /// Related SQLite: `sqlite3_step`, `sqlite3_reset` + public func query() -> ResultCode { + withStatementCleanup(self, clearBindings: false) { + StatementQueryRunner(statement: self).runNoRow() + } + } + + /// Binds a value and evaluates the prepared statement once. + /// - Parameter binding: Value to encode and bind. + /// - Returns: Result code from stepping the statement, or `.misuse` when binding fails or a row is produced. + /// + /// The statement is reset before returning and bindings are cleared. + /// + /// Related SQLite: `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings` + public func query(_ binding: Binding) -> ResultCode { + withStatementCleanup(self, clearBindings: true) { + guard bind(binding) else { + return .misuse + } + return StatementQueryRunner(statement: self).runNoRow() + } + } + + /// Evaluates the statement and decodes at most one row. + /// - Parameter type: Row type to decode. + /// - Returns: A result code and the decoded row when present. + /// + /// The statement is reset before returning. Returns `.misuse` when decoding fails or when more than one row is produced. + /// + /// Related SQLite: `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset` + public func query(row type: Row.Type = Row.self) -> (ResultCode, Row?) { + withStatementCleanup(self, clearBindings: false) { + StatementQueryRunner(statement: self).runSingle(type) + } + } + + /// Evaluates the statement and decodes all rows. + /// - Parameter type: Row type to decode. + /// - Returns: A result code and the decoded rows. + /// + /// The statement is reset before returning. Returns `.misuse` when decoding fails. + /// + /// Related SQLite: `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset` + public func query(rows type: Row.Type = Row.self) -> (ResultCode, [Row]) { + withStatementCleanup(self, clearBindings: false) { + StatementQueryRunner(statement: self).runMany(type) + } + } + + /// Binds a value, evaluates the statement, and decodes at most one row. + /// - Parameters: + /// - binding: Value to encode and bind. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded row when present. + /// + /// The statement is reset before returning and bindings are cleared. Returns `.misuse` when binding or decoding fails, or when more than one row is produced. + /// + /// Related SQLite: `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings` + public func query(_ binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?) { + withStatementCleanup(self, clearBindings: true) { + guard bind(binding) else { + return (.misuse, nil) + } + return StatementQueryRunner(statement: self).runSingle(type) + } + } + + /// Binds a value, evaluates the statement, and decodes all rows. + /// - Parameters: + /// - binding: Value to encode and bind. + /// - type: Row type to decode. + /// - Returns: A result code and the decoded rows. + /// + /// The statement is reset before returning and bindings are cleared. Returns `.misuse` when binding or decoding fails. + /// + /// Related SQLite: `sqlite3_bind_parameter_count`, `sqlite3_bind_parameter_name`, `sqlite3_bind_blob`, `sqlite3_bind_text`, `sqlite3_bind_int64`, `sqlite3_bind_double`, `sqlite3_bind_null`, `sqlite3_bind_zeroblob`, `sqlite3_column_count`, `sqlite3_column_name`, `sqlite3_column_blob`, `sqlite3_column_bytes`, `sqlite3_column_text`, `sqlite3_column_int64`, `sqlite3_column_double`, `sqlite3_column_type`, `sqlite3_step`, `sqlite3_reset`, `sqlite3_clear_bindings` + public func query(_ binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row]) { + withStatementCleanup(self, clearBindings: true) { + guard bind(binding) else { + return (.misuse, []) + } + return StatementQueryRunner(statement: self).runMany(type) + } + } +} + +private func withStatementCleanup(_ statement: Statement, clearBindings: Bool, _ body: () -> T) -> T { + defer { + _ = statement.reset() + if clearBindings { + _ = statement.clearBindings() + } + } + return body() +} + +private struct StatementQueryRunner { + let statement: Statement + + func runNoRow() -> ResultCode { + let result = statement.step() + guard result != .row else { + return .misuse + } + return result + } + + func runSingle(_ type: Row.Type) -> (ResultCode, Row?) { + let firstStep = statement.step() + switch firstStep { + case .done: + return (.done, nil) + case .row: + guard let columnMap = buildColumnMap() else { + return (.misuse, nil) + } + guard let row = decodeRow(type, columnMap: columnMap) else { + return (.misuse, nil) + } + let secondStep = statement.step() + switch secondStep { + case .done: + return (.done, row) + case .row: + return (.misuse, nil) + default: + return (secondStep, nil) + } + default: + return (firstStep, nil) + } + } + + func runMany(_ type: Row.Type) -> (ResultCode, [Row]) { + var rows: [Row] = [] + var columnMap: StatementColumnMap? + while true { + let stepResult = statement.step() + switch stepResult { + case .row: + if columnMap == nil { + columnMap = buildColumnMap() + } + guard let map = columnMap else { + return (.misuse, []) + } + guard let row = decodeRow(type, columnMap: map) else { + return (.misuse, []) + } + rows.append(row) + case .done: + return (.done, rows) + default: + return (stepResult, []) + } + } + } + + private func buildColumnMap() -> StatementColumnMap? { + do { + return try statementColumnMap(for: statement) + } catch { + return nil + } + } + + private func decodeRow(_ type: Row.Type, columnMap: StatementColumnMap) -> Row? { + do { + let decoder = StatementRowDecoder(statement: statement, columnMap: columnMap) + let value = try Row(from: decoder) + if decoder.failure != nil { + return nil + } + if decoder.decodedColumnCount != decoder.columnCount { + return nil + } + return value + } catch { + return nil + } + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+MigrationTests.swift b/Tests/LSQLiteExtensionsTests/Connection+MigrationTests.swift new file mode 100644 index 0000000..8e94176 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+MigrationTests.swift @@ -0,0 +1,58 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+Migration") +final class ConnectionMigrationTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("migrations apply in order and update version") + func migrationsApplyInOrderAndUpdateVersion() throws { + #expect(connection.setUserVersion(0) == .done) + var applied: [Int32] = [] + let migrations = [ + Connection.Migration(version: 2) { _ in + applied.append(2) + return .ok + }, + Connection.Migration(version: 1) { _ in + applied.append(1) + return .ok + } + ] + #expect(connection.migrate(migrations) == .done) + #expect(applied == [1, 2]) + + let version = connection.userVersion() + #expect(version.0 == .done) + let value = try #require(version.1) + #expect(value == 2) + + applied.removeAll() + #expect(connection.migrate(migrations) == .done) + #expect(applied.isEmpty) + } + + @Test("duplicate versions are misuse") + func duplicateVersionsAreMisuse() { + #expect(connection.setUserVersion(0) == .done) + let migrations = [ + Connection.Migration(version: 1) { _ in .ok }, + Connection.Migration(version: 1) { _ in .ok } + ] + #expect(connection.migrate(migrations) == .misuse) + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift new file mode 100644 index 0000000..f40ce67 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift @@ -0,0 +1,194 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+Pragma") +final class ConnectionPragmaTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("user version round-trips") + func userVersionRoundTrips() throws { + #expect(connection.setUserVersion(42) == .done) + let result = connection.userVersion() + #expect(result.0 == .done) + let value = try #require(result.1) + #expect(value == 42) + } + + @Test("application id round-trips") + func applicationIdRoundTrips() throws { + #expect(connection.setApplicationId(1337) == .done) + let result = connection.applicationId() + #expect(result.0 == .done) + let value = try #require(result.1) + #expect(value == 1337) + } + + @Test("foreign keys toggles") + func foreignKeysToggles() throws { + #expect(connection.setForeignKeysEnabled(true) == .done) + let enabled = connection.foreignKeysEnabled() + #expect(enabled.0 == .done) + let enabledValue = try #require(enabled.1) + #expect(enabledValue) + + #expect(connection.setForeignKeysEnabled(false) == .done) + let disabled = connection.foreignKeysEnabled() + #expect(disabled.0 == .done) + let disabledValue = try #require(disabled.1) + #expect(!disabledValue) + } + + @Test("journal mode reads and sets") + func journalModeReadsAndSets() throws { + let read = connection.journalMode() + #expect(read.0 == .done) + _ = try #require(read.1) + + let set = connection.setJournalMode(.memory) + #expect(set.0 == .done) + let mode = try #require(set.1) + #expect(mode.rawValue == Connection.JournalMode.memory.rawValue) + } + + @Test("synchronous mode round-trips") + func synchronousModeRoundTrips() throws { + #expect(connection.setSynchronousMode(.normal) == .done) + let read = connection.synchronousMode() + #expect(read.0 == .done) + let mode = try #require(read.1) + #expect(mode.rawValue == Connection.SynchronousMode.normal.rawValue) + } + + @Test("busy timeout round-trips") + func busyTimeoutRoundTrips() throws { + #expect(connection.setBusyTimeout(250) == .done) + let read = connection.busyTimeout() + #expect(read.0 == .done) + let value = try #require(read.1) + #expect(value == 250) + } + + @Test("temp store round-trips") + func tempStoreRoundTrips() throws { + #expect(connection.setTempStore(.memory) == .done) + let read = connection.tempStore() + #expect(read.0 == .done) + let mode = try #require(read.1) + #expect(mode.rawValue == Connection.TempStore.memory.rawValue) + } + + @Test("cache size round-trips") + func cacheSizeRoundTrips() throws { + #expect(connection.setCacheSize(128) == .done) + let read = connection.cacheSize() + #expect(read.0 == .done) + let value = try #require(read.1) + #expect(value == 128) + } + + @Test("mmap size round-trips") + func mmapSizeRoundTrips() throws { + #expect(connection.setMmapSize(0) == .done) + let read = connection.mmapSize() + #expect(read.0 == .done) + if let value = read.1 { + #expect(value == 0) + } + } + + @Test("locking mode reads and sets") + func lockingModeReadsAndSets() throws { + let read = connection.lockingMode() + #expect(read.0 == .done) + _ = try #require(read.1) + + let set = connection.setLockingMode(.normal) + #expect(set.0 == .done) + _ = try #require(set.1) + } + + @Test("auto vacuum reads") + func autoVacuumReads() throws { + let read = connection.autoVacuum() + #expect(read.0 == .done) + _ = try #require(read.1) + } + + @Test("incremental vacuum completes") + func incrementalVacuumCompletes() throws { + #expect(connection.incrementalVacuum() == .done) + } + + @Test("integrity check reports ok") + func integrityCheckReportsOk() throws { + let result = connection.integrityCheck() + #expect(result.0 == .done) + let message = try #require(result.1.first) + #expect(message == "ok") + } + + @Test("quick check reports ok") + func quickCheckReportsOk() throws { + let result = connection.quickCheck() + #expect(result.0 == .done) + let message = try #require(result.1.first) + #expect(message == "ok") + } + + @Test("compile options returns values") + func compileOptionsReturnsValues() throws { + let result = connection.compileOptions() + #expect(result.0 == .done) + #expect(!result.1.isEmpty) + } + + @Test("page size reads") + func pageSizeReads() throws { + let result = connection.pageSize() + #expect(result.0 == .done) + _ = try #require(result.1) + } + + @Test("schema pragmas return metadata") + func schemaPragmasReturnMetadata() throws { + #expect(connection.exec("CREATE TABLE parent(id INTEGER PRIMARY KEY);") == .ok) + #expect(connection.exec("CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id));") == .ok) + #expect(connection.exec("CREATE INDEX child_parent_idx ON child(parent_id);") == .ok) + + let tableInfo = connection.tableInfo("child") + #expect(tableInfo.0 == .done) + let column = try #require(tableInfo.1.first { $0.name == "parent_id" }) + #expect(column.name == "parent_id") + + let indexList = connection.indexList("child") + #expect(indexList.0 == .done) + let indexEntry = try #require(indexList.1.first { $0.name == "child_parent_idx" }) + #expect(indexEntry.isUnique == 0) + + let indexInfo = connection.indexInfo("child_parent_idx") + #expect(indexInfo.0 == .done) + let indexColumn = try #require(indexInfo.1.first { $0.name == "parent_id" }) + #expect(indexColumn.name == "parent_id") + + let foreignKeys = connection.foreignKeyList("child") + #expect(foreignKeys.0 == .done) + let foreignKey = try #require(foreignKeys.1.first) + #expect(foreignKey.tableName == "parent") + #expect(foreignKey.fromColumn == "parent_id") + #expect(foreignKey.toColumn == "id") + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift b/Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift new file mode 100644 index 0000000..084c609 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift @@ -0,0 +1,58 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+Query") +final class ConnectionQueryTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("no-row query reports misuse when rows are produced") + func noRowQueryReportsMisuseWhenRowsAreProduced() throws { + #expect(connection.query("SELECT 1 AS value") == .misuse) + } + + @Test("single-row query reports misuse on second row") + func singleRowQueryReportsMisuseOnSecondRow() throws { + struct Row: Decodable { + let value: Int + } + + let result = connection.query("SELECT 1 AS value UNION ALL SELECT 2 AS value", row: Row.self) + #expect(result.0 == .misuse) + #expect(result.1 == nil) + } + + @Test("connection query is reusable") + func connectionQueryIsReusable() throws { + struct Input: Encodable { + let value: Int + } + + struct Row: Decodable { + let value: Int + } + + let first = connection.query("SELECT :value AS value", binding: Input(value: 10), row: Row.self) + #expect(first.0 == .done) + let firstRow = try #require(first.1) + #expect(firstRow.value == 10) + + let second = connection.query("SELECT :value AS value", binding: Input(value: 20), row: Row.self) + #expect(second.0 == .done) + let secondRow = try #require(second.1) + #expect(secondRow.value == 20) + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+SchemaTests.swift b/Tests/LSQLiteExtensionsTests/Connection+SchemaTests.swift new file mode 100644 index 0000000..3898ac0 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+SchemaTests.swift @@ -0,0 +1,53 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+Schema") +final class ConnectionSchemaTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + try #require(connection.exec("CREATE TABLE people(id INTEGER PRIMARY KEY, name TEXT);") == .ok) + try #require(connection.exec("CREATE VIEW people_view AS SELECT name FROM people;") == .ok) + try #require(connection.exec("CREATE INDEX people_name_index ON people(name);") == .ok) + try #require(connection.exec("CREATE TRIGGER people_insert AFTER INSERT ON people BEGIN SELECT 1; END;") == .ok) + } + + deinit { + _ = connection.close() + } + + @Test("tables are discoverable") + func tablesAreDiscoverable() { + let result = connection.tables() + #expect(result.0 == .done) + #expect(result.1.contains(where: { $0.name == "people" })) + } + + @Test("views are discoverable") + func viewsAreDiscoverable() { + let result = connection.views() + #expect(result.0 == .done) + #expect(result.1.contains(where: { $0.name == "people_view" })) + } + + @Test("indexes are discoverable") + func indexesAreDiscoverable() { + let result = connection.indexes() + #expect(result.0 == .done) + #expect(result.1.contains(where: { $0.name == "people_name_index" })) + } + + @Test("triggers are discoverable") + func triggersAreDiscoverable() { + let result = connection.triggers() + #expect(result.0 == .done) + #expect(result.1.contains(where: { $0.name == "people_insert" })) + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift b/Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift new file mode 100644 index 0000000..4023362 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift @@ -0,0 +1,52 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+Transaction") +final class ConnectionTransactionTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + try #require(connection.exec("CREATE TABLE t(id INTEGER);") == .ok) + } + + deinit { + _ = connection.close() + } + + @Test("begin/commit toggles autocommit") + func beginCommitTogglesAutocommit() throws { + #expect(connection.isAutocommit) + #expect(connection.beginTransaction() == .ok) + #expect(!connection.isAutocommit) + #expect(connection.commitTransaction() == .ok) + #expect(connection.isAutocommit) + } + + @Test("rollback restores autocommit") + func rollbackRestoresAutocommit() throws { + #expect(connection.beginTransaction() == .ok) + #expect(!connection.isAutocommit) + #expect(connection.rollbackTransaction() == .ok) + #expect(connection.isAutocommit) + } + + @Test("nested transaction leaves outer active") + func nestedTransactionLeavesOuterActive() throws { + #expect(connection.beginTransaction() == .ok) + #expect(!connection.isAutocommit) + let inner = connection.transaction { db in + db.exec("INSERT INTO t(id) VALUES (1);") + } + #expect(inner == .ok) + #expect(!connection.isAutocommit) + #expect(connection.rollbackTransaction() == .ok) + #expect(connection.isAutocommit) + } +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+WALTests.swift b/Tests/LSQLiteExtensionsTests/Connection+WALTests.swift new file mode 100644 index 0000000..1302eee --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+WALTests.swift @@ -0,0 +1,39 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Connection+WAL") +final class ConnectionWALTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("auto checkpoint reads value") + func autoCheckpointReadsValue() throws { + #expect(connection.autoWALCheckpoint(pageInWALFileCount: 1) == .ok) + let read = connection.autoWALCheckpoint() + #expect(read.0 == .done) + let value = try #require(read.1) + #expect(value == 1) + } + + @Test("checkpoint returns frame counts") + func checkpointReturnsFrameCounts() throws { + let result = connection.walCheckpoint() + #expect(result.0 == .ok) + let info = result.1 + #expect(info.frameCount >= -1) + #expect(info.checkpointedFrameCount >= -1) + } +} diff --git a/Tests/LSQLiteExtensionsTests/LSQLiteExtensionsTests.swift b/Tests/LSQLiteExtensionsTests/LSQLiteExtensionsTests.swift new file mode 100644 index 0000000..07b8f27 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/LSQLiteExtensionsTests.swift @@ -0,0 +1,5 @@ +import LSQLiteExtensions +import Testing + +@Suite("LSQLiteExtensions") +struct LSQLiteExtensionsTests {} diff --git a/Tests/LSQLiteExtensionsTests/Statement+CodableTests.swift b/Tests/LSQLiteExtensionsTests/Statement+CodableTests.swift new file mode 100644 index 0000000..0c88984 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Statement+CodableTests.swift @@ -0,0 +1,135 @@ +import Foundation +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Statement+Codable") +final class StatementCodableTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("bind encodes supported values") + func bindEncodesSupportedValues() throws { + struct Input: Encodable { + let name: String + let age: Int + let score: Double + let blob: Data + let noneValue: String? + + enum CodingKeys: String, CodingKey { + case name + case age + case score + case blob + case noneValue + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(age, forKey: .age) + try container.encode(score, forKey: .score) + try container.encode(blob, forKey: .blob) + try container.encodeNil(forKey: .noneValue) + } + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT :name AS name, :age AS age, :score AS score, :blob AS blob, :noneValue AS noneValue", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + let input = Input(name: "Ada", age: 42, score: 98.5, blob: Data([0x0A, 0x0B]), noneValue: nil) + #expect(prepared.bind(input)) + #expect(prepared.step() == .row) + #expect(prepared.columnText(at: 0) == "Ada") + #expect(prepared.columnInt64(at: 1) == 42) + #expect(prepared.columnDouble(at: 2) == 98.5) + #expect(prepared.columnBytes(at: 3) == 2) + #expect(prepared.columnBlob(at: 3) != nil) + #expect(prepared.columnType(at: 4) == .null) + #expect(prepared.step() == .done) + } + + @Test("bind rejects missing keys") + func bindRejectsMissingKeys() throws { + struct Input: Encodable { + let name: String + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT :name AS name, :age AS age", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + #expect(prepared.bind(Input(name: "Ada")) == false) + } + + @Test("bind rejects unsupported types") + func bindRejectsUnsupportedTypes() throws { + struct Input: Encodable { + let flag: Bool + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT :flag AS flag", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + #expect(prepared.bind(Input(flag: true)) == false) + } + + @Test("row decodes supported values") + func rowDecodesSupportedValues() throws { + struct Row: Decodable { + let name: String + let age: Int + let score: Double + let blob: Data + let noneValue: String? + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT 'Ada' AS name, 42 AS age, 98.5 AS score, X'0A0B' AS blob, NULL AS noneValue", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + #expect(prepared.step() == .row) + let row = try #require(prepared.row(Row.self)) + #expect(row.name == "Ada") + #expect(row.age == 42) + #expect(row.score == 98.5) + #expect(row.blob == Data([0x0A, 0x0B])) + #expect(row.noneValue == nil) + #expect(prepared.step() == .done) + } + + @Test("row rejects column mismatch") + func rowRejectsColumnMismatch() throws { + struct Row: Decodable { + let one: Int + let two: Int + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT 1 AS one", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + #expect(prepared.step() == .row) + #expect(prepared.row(Row.self) == nil) + } +} diff --git a/Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift b/Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift new file mode 100644 index 0000000..2edc3a4 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift @@ -0,0 +1,73 @@ +import LSQLite +import LSQLiteExtensions +import Testing + +@Suite("Statement+Query") +final class StatementQueryTests { + private let connection: Connection + + init() throws { + let connection: Connection = try { + var connection: Connection? + try #require(Connection.open(&connection, at: .memory, withOpenFlags: [.readwrite, .create]) == .ok) + return try #require(connection) + }() + self.connection = connection + } + + deinit { + _ = connection.close() + } + + @Test("no-row query reports misuse when rows are produced") + func noRowQueryReportsMisuseWhenRowsAreProduced() throws { + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT 1 AS value", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + #expect(prepared.query() == .misuse) + } + + @Test("single-row query reports misuse on second row") + func singleRowQueryReportsMisuseOnSecondRow() throws { + struct Row: Decodable { + let value: Int + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT 1 AS value UNION ALL SELECT 2 AS value", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + let result = prepared.query(row: Row.self) + #expect(result.0 == .misuse) + #expect(result.1 == nil) + } + + @Test("statement query is reusable") + func statementQueryIsReusable() throws { + struct Input: Encodable { + let value: Int + } + + struct Row: Decodable { + let value: Int + } + + var statement: Statement? + try #require(Statement.prepare(&statement, sql: "SELECT :value AS value", for: connection) == .ok) + let prepared = try #require(statement) + defer { _ = prepared.finalize() } + + let first = prepared.query(Input(value: 1), row: Row.self) + #expect(first.0 == .done) + let firstRow = try #require(first.1) + #expect(firstRow.value == 1) + + let second = prepared.query(Input(value: 2), row: Row.self) + #expect(second.0 == .done) + let secondRow = try #require(second.1) + #expect(secondRow.value == 2) + } +}