From e3f5ab876d9de59e355d695d52800994b9d97381 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 13:50:24 +0200 Subject: [PATCH 01/16] Add roadmap for LSQLiteExtensions with goals, constraints, milestones, testing, and documentation --- LSQLiteExtensionsPlan.md | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 LSQLiteExtensionsPlan.md diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md new file mode 100644 index 0000000..06fb974 --- /dev/null +++ b/LSQLiteExtensionsPlan.md @@ -0,0 +1,43 @@ +# LSQLiteExtensions Roadmap + +## Goals +- Make SQLite adoption easier without changing SQLite semantics. +- Eliminate common boilerplate (statement lifecycle, transactions, pragmas, schema queries). +- Provide convenient access to SQLite-specific SQL features (PRAGMA, system tables, WAL/checkpoint, schema/version metadata). + +## Constraints +- No new “primary” connection/statement types (extend existing `Connection` / `Statement`). +- Keep SQLite/LSQLite-style control flow (work with `ResultCode`, no throwing error model). +- Apple platforms only. + +## Milestones (areas of interest) +1. Package scaffolding + - Add the `LSQLiteExtensions` target + product and wire it into CI/tests. + +2. Statement lifecycle conveniences + - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. + +3. Transactions & savepoints + - Transaction helpers (begin/commit/rollback) and nested transactional behavior via savepoints. + +4. Pragmas + - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. + +5. Introspection & system tables + - Helpers for working with `sqlite_schema` / `sqlite_master` and schema discovery (tables/views/indexes/triggers). + - Helpers for pragma-driven metadata (table info, index info, foreign keys, etc). + +6. Migrations + - Versioning helpers (e.g. `user_version`) and a migration runner built on the previous features. + +7. Codable + - Optional Codable-based binding/decoding helpers for statement parameters and result columns. + +8. WAL & checkpoint + - Convenience APIs around WAL checkpointing and related WAL controls. + +## Testing +- Swift Testing suites per feature file, using in-memory databases and asserting via `ResultCode`. + +## Documentation & examples +- Public docs for new symbols (with “Related SQLite:” lists), plus README recipes per feature. From 5cef1249dac7b0c48a44a283187a0871e0ff107e Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 14:06:47 +0200 Subject: [PATCH 02/16] Add LSQLiteExtensions library and corresponding tests --- LSQLiteExtensionsPlan.md | 2 ++ Package.swift | 12 ++++++++++++ Sources/LSQLiteExtensions/LSQLiteExtensions.swift | 1 + .../LSQLiteExtensionsTests.swift | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 Sources/LSQLiteExtensions/LSQLiteExtensions.swift create mode 100644 Tests/LSQLiteExtensionsTests/LSQLiteExtensionsTests.swift diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index 06fb974..2b4ce18 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -14,6 +14,8 @@ 1. Package scaffolding - Add the `LSQLiteExtensions` target + product and wire it into CI/tests. +--- above are implemented --- + 2. Statement lifecycle conveniences - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. 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/Sources/LSQLiteExtensions/LSQLiteExtensions.swift b/Sources/LSQLiteExtensions/LSQLiteExtensions.swift new file mode 100644 index 0000000..49fa522 --- /dev/null +++ b/Sources/LSQLiteExtensions/LSQLiteExtensions.swift @@ -0,0 +1 @@ +import LSQLite 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 {} From d9bd849e7d7f986df47e70405c355af418eb4edb Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 14:42:31 +0200 Subject: [PATCH 03/16] Update plan for binding and rows --- LSQLiteExtensionsPlan.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index 2b4ce18..5c04330 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -16,25 +16,37 @@ --- above are implemented --- -2. Statement lifecycle conveniences +2. Codable binding & decoding + - Add statement-backed Codable helpers for binding and row decoding (no intermediate representation). + - Implement custom `Encoder` / `Decoder` that operate directly on `Statement` (bind/read) with no intermediate formats (no JSON, no plist, no other serialization) for performance. + - If key discovery is needed, implement a separate lightweight “key-only” encoder that captures coding keys and ignores values, rather than encoding to an intermediate representation. + - `Statement.bind(_ binding: Binding) -> Bool` + - Support only named parameters with `:` prefix. + - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other encoded type fails. + - Require an exact match between statement parameter names and encoded keys; missing or extra parameters fail. + - Support only flat types (top-level keyed container; no nested/unkeyed containers). + - `Statement.row(_: Row.Type = Row.self) -> Row?` + - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other decoded type fails. + - Require an exact match between result column names and decoded keys; missing or extra columns fail. + - Support only flat types (top-level keyed container; no nested/unkeyed containers). + +3. Statement lifecycle conveniences - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. + - Implement one-shot patterns as `Connection` extensions. -3. Transactions & savepoints +4. Transactions & savepoints - Transaction helpers (begin/commit/rollback) and nested transactional behavior via savepoints. -4. Pragmas +5. Pragmas - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. -5. Introspection & system tables +6. Introspection & system tables - Helpers for working with `sqlite_schema` / `sqlite_master` and schema discovery (tables/views/indexes/triggers). - Helpers for pragma-driven metadata (table info, index info, foreign keys, etc). -6. Migrations +7. Migrations - Versioning helpers (e.g. `user_version`) and a migration runner built on the previous features. -7. Codable - - Optional Codable-based binding/decoding helpers for statement parameters and result columns. - 8. WAL & checkpoint - Convenience APIs around WAL checkpointing and related WAL controls. From 8c31a4239ff31130aa7f4fe704e0382ff0bcdac3 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 15:26:41 +0200 Subject: [PATCH 04/16] Add Codable support for SQLite Statement rows - Implemented `Statement+Row.swift` to decode rows into Decodable types using column names as keys. - Created `StatementRowDecoder.swift` to handle the decoding process and manage column mappings. - Introduced `StatementCodingFailure.swift` to define error cases for coding failures. - Removed the old `Statement+Codable.swift` file to streamline the codebase and avoid redundancy. --- .../Coding/Binding/Statement+Binding.swift | 37 ++ .../Binding/StatementBindingEncoder.swift | 172 +++++++++ .../StatementKeyCollectorEncoder.swift | 127 +++++++ .../Binding/StatementParameterMap.swift | 28 ++ .../StatementFailingEncodingContainers.swift | 333 ++++++++++++++++++ .../Coding/Row/Statement+Row.swift | 29 ++ .../Coding/Row/StatementRowDecoder.swift | 208 +++++++++++ .../Coding/StatementCodingFailure.swift | 10 + .../Statement+CodableTests.swift | 135 +++++++ 9 files changed, 1079 insertions(+) create mode 100644 Sources/LSQLiteExtensions/Coding/Binding/Statement+Binding.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Binding/StatementBindingEncoder.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Binding/StatementParameterMap.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Row/Statement+Row.swift create mode 100644 Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift create mode 100644 Sources/LSQLiteExtensions/Coding/StatementCodingFailure.swift create mode 100644 Tests/LSQLiteExtensionsTests/Statement+CodableTests.swift 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..e831801 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift @@ -0,0 +1,127 @@ +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: Bool, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + encoder.record(key) + } + + mutating func encode(_ value: UInt64, 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..4a39642 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift @@ -0,0 +1,333 @@ +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: Bool, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: String, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt64, 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: Bool) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: String) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Double) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Float) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int8) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int16) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int32) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int64) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt8) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt16) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt32) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt64) 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: Bool) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: String) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Double) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Float) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int8) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int16) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int32) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: Int64) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt8) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt16) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt32) throws { + failure(.unsupportedContainer) + throw StatementCodingFailure.unsupportedContainer + } + + mutating func encode(_ value: UInt64) 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..2cc3240 --- /dev/null +++ b/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift @@ -0,0 +1,208 @@ +import Foundation +import LSQLite + +final class StatementRowDecoder: Decoder { + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var failure: StatementCodingFailure? + let statement: Statement + let columnMap: [String: Int32] + let columnCount: Int + var decodedIndices: Set = [] + + init(statement: Statement) throws { + let count = Int(statement.columnCount) + var map: [String: Int32] = [:] + map.reserveCapacity(count) + if count > 0 { + for offset in 0..(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/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) + } +} From 80509c9b3d61162393ca7a8c9618d502ec169056 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 15:40:10 +0200 Subject: [PATCH 05/16] Update LSQLiteExtensions roadmap and README for clarity and implementation status --- LSQLiteExtensionsPlan.md | 12 ++++++------ README.md | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index 5c04330..cfdc37b 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -14,8 +14,6 @@ 1. Package scaffolding - Add the `LSQLiteExtensions` target + product and wire it into CI/tests. ---- above are implemented --- - 2. Codable binding & decoding - Add statement-backed Codable helpers for binding and row decoding (no intermediate representation). - Implement custom `Encoder` / `Decoder` that operate directly on `Statement` (bind/read) with no intermediate formats (no JSON, no plist, no other serialization) for performance. @@ -25,10 +23,12 @@ - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other encoded type fails. - Require an exact match between statement parameter names and encoded keys; missing or extra parameters fail. - Support only flat types (top-level keyed container; no nested/unkeyed containers). - - `Statement.row(_: Row.Type = Row.self) -> Row?` - - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other decoded type fails. - - Require an exact match between result column names and decoded keys; missing or extra columns fail. - - Support only flat types (top-level keyed container; no nested/unkeyed containers). + - `Statement.row(_: Row.Type = Row.self) -> Row?` + - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other decoded type fails. + - Require an exact match between result column names and decoded keys; missing or extra columns fail. + - Support only flat types (top-level keyed container; no nested/unkeyed containers). + +--- above are implemented --- 3. Statement lifecycle conveniences - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. 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: From 5eb47d3ff272eb04dea4c30c11d48f7a4f77bf37 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 16:02:01 +0200 Subject: [PATCH 06/16] Enhance Statement and Connection APIs with new query overloads for improved usability and strictness rules --- LSQLiteExtensionsPlan.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index cfdc37b..568efb1 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -32,7 +32,37 @@ 3. Statement lifecycle conveniences - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. - - Implement one-shot patterns as `Connection` extensions. + - Add exactly **6** `query` overloads on `Statement` (prepared statement helpers) and exactly **6** `query` overloads on `Connection` (one-shot helpers that prepare/finalize and delegate to `Statement`). + - `Statement` API surface (6 overloads): + - `query() -> ResultCode` + - `query(_ binding: Binding) -> ResultCode` + - `query(row type: Row.Type = Row.self) -> (ResultCode, Row?)` + - `query(rows type: Row.Type = Row.self) -> (ResultCode, [Row])` + - `query(_ binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?)` + - `query(_ binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` + - `Connection` API surface (6 overloads, implemented via `Statement.prepare` + `Statement.query` + `finalize()`): + - `query(_ sql: String) -> ResultCode` + - `query(_ sql: String, binding: Binding) -> ResultCode` + - `query(_ sql: String, row type: Row.Type = Row.self) -> (ResultCode, Row?)` + - `query(_ sql: String, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` + - `query(_ sql: String, binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?)` + - `query(_ sql: String, binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` + - Strictness rules: + - No-row overloads fail with `.misuse` if a row is produced. + - Single-row overloads must enforce **0 or 1** row total; if a second row exists, fail with `.misuse`. + - Binding failure (via `Statement.bind(_:)`) fails with `.misuse`. + - Row decoding failure (via `Statement.row(_:)`) fails with `.misuse`. + - Underlying SQLite result codes from stepping propagate (for example `.busy`, `.error`). + - `Statement` is left reusable: always `reset()` before returning; binding overloads also `clearBindings()` (at least on success, ideally on all paths). + - Implementation notes: + - Add `Statement+Query.swift` in `Sources/LSQLiteExtensions` with a small internal helper that runs the statement and (optionally) collects/validates rows. + - Add `Connection+Query.swift` in `Sources/LSQLiteExtensions` that prepares exactly one statement, delegates to `Statement.query`, and finalizes via `defer`. + - If the SQL string contains multiple statements, only the first prepared statement is executed; any remaining SQL (including comments) is ignored. + - Performance: for row-returning overloads, build/validate the result column name map once per query (not per row) and reuse it while stepping; similarly, avoid recomputing the statement parameter map multiple times within a single query call. + - Tests (Swift Testing): + - `Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift` (`@Suite("Statement+Query")`) + - `Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift` (`@Suite("Connection+Query")`) + - Cover: `.misuse` on >1 row for single-row overloads, `.misuse` when using no-row overloads on a row-producing statement, and statement reusability across multiple calls. 4. Transactions & savepoints - Transaction helpers (begin/commit/rollback) and nested transactional behavior via savepoints. From 161ccb91bd7c0e9624ac00f45f2cb4c5175615c8 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Sat, 31 Jan 2026 16:19:02 +0200 Subject: [PATCH 07/16] Add query methods to Connection and Statement extensions for improved SQL execution and result handling --- .../Coding/Row/StatementRowDecoder.swift | 50 +++-- .../LSQLiteExtensions/Connection+Query.swift | 139 +++++++++++++ .../LSQLiteExtensions/Statement+Query.swift | 190 ++++++++++++++++++ .../Connection+QueryTests.swift | 58 ++++++ .../Statement+QueryTests.swift | 73 +++++++ 5 files changed, 493 insertions(+), 17 deletions(-) create mode 100644 Sources/LSQLiteExtensions/Connection+Query.swift create mode 100644 Sources/LSQLiteExtensions/Statement+Query.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift create mode 100644 Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift diff --git a/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift b/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift index 2cc3240..df36f9d 100644 --- a/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift +++ b/Sources/LSQLiteExtensions/Coding/Row/StatementRowDecoder.swift @@ -1,6 +1,30 @@ 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 count = Int(statement.columnCount) - var map: [String: Int32] = [:] - map.reserveCapacity(count) - if count > 0 { - for offset in 0..(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { 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/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+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/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) + } +} From d568803960de6842e6f958d06fd52f556ce30f67 Mon Sep 17 00:00:00 2001 From: Anton Sergeev Date: Mon, 2 Feb 2026 16:27:35 +0200 Subject: [PATCH 08/16] Remove duplicated specialized methods --- .../StatementKeyCollectorEncoder.swift | 56 ----- .../StatementFailingEncodingContainers.swift | 210 ------------------ .../LSQLiteExtensions/LSQLiteExtensions.swift | 1 - 3 files changed, 267 deletions(-) delete mode 100644 Sources/LSQLiteExtensions/LSQLiteExtensions.swift diff --git a/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift b/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift index e831801..8493d05 100644 --- a/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift +++ b/Sources/LSQLiteExtensions/Coding/Binding/StatementKeyCollectorEncoder.swift @@ -45,62 +45,6 @@ struct StatementKeyCollectorContainer: KeyedEncodingContainerPro encoder.record(key) } - mutating func encode(_ value: Bool, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: String, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Double, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Float, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Int, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Int8, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Int16, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Int32, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: Int64, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: UInt, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: UInt8, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: UInt16, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: UInt32, forKey key: Key) throws { - encoder.record(key) - } - - mutating func encode(_ value: UInt64, forKey key: Key) throws { - encoder.record(key) - } - mutating func encode(_ value: T, forKey key: Key) throws { encoder.record(key) } diff --git a/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift b/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift index 4a39642..3e2e326 100644 --- a/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift +++ b/Sources/LSQLiteExtensions/Coding/Common/StatementFailingEncodingContainers.swift @@ -14,76 +14,6 @@ struct StatementFailingKeyedEncodingContainer: KeyedEncodingCont throw StatementCodingFailure.unsupportedContainer } - mutating func encode(_ value: Bool, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: String, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Double, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Float, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int8, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int16, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int32, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int64, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt8, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt16, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt32, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt64, forKey key: Key) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - mutating func encode(_ value: T, forKey key: Key) throws { failure(.unsupportedContainer) throw StatementCodingFailure.unsupportedContainer @@ -125,76 +55,6 @@ struct StatementFailingUnkeyedEncodingContainer: UnkeyedEncodingContainer { throw StatementCodingFailure.unsupportedContainer } - mutating func encode(_ value: Bool) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: String) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Double) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Float) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int8) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int16) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int32) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int64) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt8) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt16) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt32) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt64) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - mutating func encode(_ value: T) throws { failure(.unsupportedContainer) throw StatementCodingFailure.unsupportedContainer @@ -230,76 +90,6 @@ struct StatementFailingSingleValueEncodingContainer: SingleValueEncodingContaine throw StatementCodingFailure.unsupportedContainer } - mutating func encode(_ value: Bool) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: String) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Double) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Float) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int8) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int16) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int32) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: Int64) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt8) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt16) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt32) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - - mutating func encode(_ value: UInt64) throws { - failure(.unsupportedContainer) - throw StatementCodingFailure.unsupportedContainer - } - mutating func encode(_ value: T) throws { failure(.unsupportedContainer) throw StatementCodingFailure.unsupportedContainer diff --git a/Sources/LSQLiteExtensions/LSQLiteExtensions.swift b/Sources/LSQLiteExtensions/LSQLiteExtensions.swift deleted file mode 100644 index 49fa522..0000000 --- a/Sources/LSQLiteExtensions/LSQLiteExtensions.swift +++ /dev/null @@ -1 +0,0 @@ -import LSQLite From e88ec5ec53f02a5de1bcb9c9f48a025476233938 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 21:29:13 +0200 Subject: [PATCH 09/16] Update LSQLite plan --- LSQLiteExtensionsPlan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index 568efb1..fe3be43 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -28,8 +28,6 @@ - Require an exact match between result column names and decoded keys; missing or extra columns fail. - Support only flat types (top-level keyed container; no nested/unkeyed containers). ---- above are implemented --- - 3. Statement lifecycle conveniences - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. - Add exactly **6** `query` overloads on `Statement` (prepared statement helpers) and exactly **6** `query` overloads on `Connection` (one-shot helpers that prepare/finalize and delegate to `Statement`). @@ -64,6 +62,8 @@ - `Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift` (`@Suite("Connection+Query")`) - Cover: `.misuse` on >1 row for single-row overloads, `.misuse` when using no-row overloads on a row-producing statement, and statement reusability across multiple calls. +--- above are implemented --- + 4. Transactions & savepoints - Transaction helpers (begin/commit/rollback) and nested transactional behavior via savepoints. From 7984099167fa0dc80c9acb8207df5cee96510f0a Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 21:51:48 +0200 Subject: [PATCH 10/16] Plan and implement transaction APIs --- LSQLiteExtensionsPlan.md | 17 +- .../Connection+Transaction.swift | 146 ++++++++++++++++++ .../Connection+TransactionTests.swift | 52 +++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 Sources/LSQLiteExtensions/Connection+Transaction.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index fe3be43..d3ad4af 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -62,10 +62,21 @@ - `Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift` (`@Suite("Connection+Query")`) - Cover: `.misuse` on >1 row for single-row overloads, `.misuse` when using no-row overloads on a row-producing statement, and statement reusability across multiple calls. ---- above are implemented --- - 4. Transactions & savepoints - - Transaction helpers (begin/commit/rollback) and nested transactional behavior via savepoints. + - Add thin helpers for explicit transaction control and savepoints. + - `TransactionMode` with `deferred` / `immediate` / `exclusive` behavior. + - `SavepointName` for typed savepoint identifiers. + - `Connection.beginTransaction(_:)`, `commitTransaction()`, `rollbackTransaction()`. + - `Connection.savepoint(_:)`, `releaseSavepoint(_:)`, `rollbackToSavepoint(_:)` (quote identifiers). + - Add a closure-based helper that uses savepoints when already inside a transaction. + - `Connection.transaction(_:_:)` uses `isAutocommit` to choose `BEGIN` vs `SAVEPOINT`. + - Commit/release when body returns `.ok` or `.done`; rollback otherwise. + - Use `exec(_:)` for SQL that includes multiple statements. + - Tests (Swift Testing): + - `Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift` (`@Suite("Connection+Transaction")`) + - Cover autocommit toggling and nested savepoint behavior. + +--- above are implemented --- 5. Pragmas - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. 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/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) + } +} From eaef3abddad28dff33dec4e67c67bedcac1147b8 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 22:09:00 +0200 Subject: [PATCH 11/16] Update LSQLite pragma plan --- LSQLiteExtensionsPlan.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index d3ad4af..42e93cb 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -80,6 +80,19 @@ 5. Pragmas - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. + - Approach: typed-only APIs. Do not add a public generic “run arbitrary PRAGMA by name” API. + - Implementation notes: + - Add `Sources/LSQLiteExtensions/Connection+Pragma.swift`. + - Implement a tiny private helper that prepares exactly one PRAGMA statement via `Statement.prepare`, steps, reads column 0, enforces **0 or 1** row total, and finalizes via `defer`. + - Return `.misuse` when the PRAGMA produces more than one row, when the value type does not match the expected Swift type, or when a scalar PRAGMA unexpectedly returns no rows. + - Keep SQLite semantics: propagate `ResultCode` from prepare/step; do not introduce throwing flows. + - Avoid string interpolation of PRAGMA names. Each public method hardcodes its PRAGMA SQL; if a database name must be accepted, quote it as an identifier. + - Initial surface (examples; keep the set small and additive): + - `user_version` (`Int32` round-trip). + - `application_id` (`Int32` round-trip). + - `foreign_keys` (`Bool` read/write). + - Tests (Swift Testing): + - `Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift` (`@Suite("Connection+Pragma")`), using an in-memory database and asserting round-trips and `.misuse` strictness. 6. Introspection & system tables - Helpers for working with `sqlite_schema` / `sqlite_master` and schema discovery (tables/views/indexes/triggers). From 4c12c1a88cc0cbc18317b1b796612479eb2fe7d2 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 22:17:20 +0200 Subject: [PATCH 12/16] Implement pragma helpers --- .../LSQLiteExtensions/Connection+Pragma.swift | 163 ++++++++++++++++++ .../Connection+PragmaTests.swift | 54 ++++++ 2 files changed, 217 insertions(+) create mode 100644 Sources/LSQLiteExtensions/Connection+Pragma.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift diff --git a/Sources/LSQLiteExtensions/Connection+Pragma.swift b/Sources/LSQLiteExtensions/Connection+Pragma.swift new file mode 100644 index 0000000..1f389c3 --- /dev/null +++ b/Sources/LSQLiteExtensions/Connection+Pragma.swift @@ -0,0 +1,163 @@ +import LSQLite + +extension Connection { + /// 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 pragma 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 pragma 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) + } + + /// 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 pragma 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)") + } +} + +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 pragmaScalar(_ sql: String, 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 (.misuse, nil) + default: + return (firstStep, nil) + } + } +} + +private func quotedIdentifier(_ rawValue: String) -> String { + let escaped = rawValue.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" +} diff --git a/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift new file mode 100644 index 0000000..94a3e86 --- /dev/null +++ b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift @@ -0,0 +1,54 @@ +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) + } +} From 81f89054ee34650985feb8f22edac9ef0308d51b Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 22:36:54 +0200 Subject: [PATCH 13/16] Add sqlite pragma wrappers --- .../LSQLiteExtensions/Connection+Pragma.swift | 756 +++++++++++++++++- .../Connection+PragmaTests.swift | 140 ++++ 2 files changed, 879 insertions(+), 17 deletions(-) diff --git a/Sources/LSQLiteExtensions/Connection+Pragma.swift b/Sources/LSQLiteExtensions/Connection+Pragma.swift index 1f389c3..3019814 100644 --- a/Sources/LSQLiteExtensions/Connection+Pragma.swift +++ b/Sources/LSQLiteExtensions/Connection+Pragma.swift @@ -1,6 +1,483 @@ 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. @@ -22,7 +499,7 @@ extension Connection { /// - Parameters: /// - value: Value to store. /// - databaseName: Optional database name qualifier. - /// - Returns: Result code from stepping the pragma statement. + /// - Returns: Result code from stepping the statement. /// /// Returns `.misuse` when a row is produced. /// @@ -58,7 +535,7 @@ extension Connection { /// - Parameters: /// - value: Value to store. /// - databaseName: Optional database name qualifier. - /// - Returns: Result code from stepping the pragma statement. + /// - Returns: Result code from stepping the statement. /// /// Returns `.misuse` when a row is produced. /// @@ -73,27 +550,224 @@ extension Connection { return pragmaNoRow(sql) } - /// Reads whether foreign key enforcement is enabled. - /// - Returns: A result code and the current enabled state when available. + /// 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 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 }) + /// 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) }) } - /// Enables or disables foreign key enforcement. - /// - Parameter enabled: Whether to enable enforcement. - /// - Returns: Result code from stepping the pragma statement. + /// 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 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)") + /// 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) } } @@ -124,7 +798,55 @@ private extension Connection { } } - func pragmaScalar(_ sql: String, decode: (Statement) -> T?) -> (ResultCode, T?) { + 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 { @@ -150,7 +872,7 @@ private extension Connection { return (secondStep, nil) } case .done: - return (.misuse, nil) + return allowNoRow ? (.done, nil) : (.misuse, nil) default: return (firstStep, nil) } diff --git a/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift index 94a3e86..f40ce67 100644 --- a/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift +++ b/Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift @@ -51,4 +51,144 @@ final class ConnectionPragmaTests { 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") + } } From 6c2b48cabb8ecaf36ecde927238856b4be4284a8 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 22:37:48 +0200 Subject: [PATCH 14/16] Implement pragma helpers --- LSQLiteExtensionsPlan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md index 42e93cb..becfe51 100644 --- a/LSQLiteExtensionsPlan.md +++ b/LSQLiteExtensionsPlan.md @@ -76,8 +76,6 @@ - `Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift` (`@Suite("Connection+Transaction")`) - Cover autocommit toggling and nested savepoint behavior. ---- above are implemented --- - 5. Pragmas - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. - Approach: typed-only APIs. Do not add a public generic “run arbitrary PRAGMA by name” API. @@ -94,6 +92,8 @@ - Tests (Swift Testing): - `Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift` (`@Suite("Connection+Pragma")`), using an in-memory database and asserting round-trips and `.misuse` strictness. +--- above are implemented --- + 6. Introspection & system tables - Helpers for working with `sqlite_schema` / `sqlite_master` and schema discovery (tables/views/indexes/triggers). - Helpers for pragma-driven metadata (table info, index info, foreign keys, etc). From e7050d5a69fbe2931143ed51c2e0fe9a2b8a52c2 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 23:01:59 +0200 Subject: [PATCH 15/16] Add connection migration helpers --- .../Connection+Migration.swift | 71 +++++++ .../LSQLiteExtensions/Connection+Schema.swift | 198 ++++++++++++++++++ .../LSQLiteExtensions/Connection+WAL.swift | 78 +++++++ .../Connection+MigrationTests.swift | 58 +++++ .../Connection+SchemaTests.swift | 53 +++++ .../Connection+WALTests.swift | 39 ++++ 6 files changed, 497 insertions(+) create mode 100644 Sources/LSQLiteExtensions/Connection+Migration.swift create mode 100644 Sources/LSQLiteExtensions/Connection+Schema.swift create mode 100644 Sources/LSQLiteExtensions/Connection+WAL.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+MigrationTests.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+SchemaTests.swift create mode 100644 Tests/LSQLiteExtensionsTests/Connection+WALTests.swift 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+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+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/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+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+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) + } +} From d927fdebacbcd4b0dc7a5f4583b263c4f8507a44 Mon Sep 17 00:00:00 2001 From: antonsergeev88 Date: Mon, 2 Feb 2026 23:02:52 +0200 Subject: [PATCH 16/16] Implement LSQLiteExtensions tasks --- LSQLiteExtensionsPlan.md | 111 --------------------------------------- 1 file changed, 111 deletions(-) delete mode 100644 LSQLiteExtensionsPlan.md diff --git a/LSQLiteExtensionsPlan.md b/LSQLiteExtensionsPlan.md deleted file mode 100644 index becfe51..0000000 --- a/LSQLiteExtensionsPlan.md +++ /dev/null @@ -1,111 +0,0 @@ -# LSQLiteExtensions Roadmap - -## Goals -- Make SQLite adoption easier without changing SQLite semantics. -- Eliminate common boilerplate (statement lifecycle, transactions, pragmas, schema queries). -- Provide convenient access to SQLite-specific SQL features (PRAGMA, system tables, WAL/checkpoint, schema/version metadata). - -## Constraints -- No new “primary” connection/statement types (extend existing `Connection` / `Statement`). -- Keep SQLite/LSQLite-style control flow (work with `ResultCode`, no throwing error model). -- Apple platforms only. - -## Milestones (areas of interest) -1. Package scaffolding - - Add the `LSQLiteExtensions` target + product and wire it into CI/tests. - -2. Codable binding & decoding - - Add statement-backed Codable helpers for binding and row decoding (no intermediate representation). - - Implement custom `Encoder` / `Decoder` that operate directly on `Statement` (bind/read) with no intermediate formats (no JSON, no plist, no other serialization) for performance. - - If key discovery is needed, implement a separate lightweight “key-only” encoder that captures coding keys and ignores values, rather than encoding to an intermediate representation. - - `Statement.bind(_ binding: Binding) -> Bool` - - Support only named parameters with `:` prefix. - - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other encoded type fails. - - Require an exact match between statement parameter names and encoded keys; missing or extra parameters fail. - - Support only flat types (top-level keyed container; no nested/unkeyed containers). - - `Statement.row(_: Row.Type = Row.self) -> Row?` - - Support only `nil`, `Data`, `String`, `Int` (assume 64-bit), and `Double`; any other decoded type fails. - - Require an exact match between result column names and decoded keys; missing or extra columns fail. - - Support only flat types (top-level keyed container; no nested/unkeyed containers). - -3. Statement lifecycle conveniences - - Reduce boilerplate around prepare/bind/step/reset/finalize for common one-shot and repeated-use patterns. - - Add exactly **6** `query` overloads on `Statement` (prepared statement helpers) and exactly **6** `query` overloads on `Connection` (one-shot helpers that prepare/finalize and delegate to `Statement`). - - `Statement` API surface (6 overloads): - - `query() -> ResultCode` - - `query(_ binding: Binding) -> ResultCode` - - `query(row type: Row.Type = Row.self) -> (ResultCode, Row?)` - - `query(rows type: Row.Type = Row.self) -> (ResultCode, [Row])` - - `query(_ binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?)` - - `query(_ binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` - - `Connection` API surface (6 overloads, implemented via `Statement.prepare` + `Statement.query` + `finalize()`): - - `query(_ sql: String) -> ResultCode` - - `query(_ sql: String, binding: Binding) -> ResultCode` - - `query(_ sql: String, row type: Row.Type = Row.self) -> (ResultCode, Row?)` - - `query(_ sql: String, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` - - `query(_ sql: String, binding: Binding, row type: Row.Type = Row.self) -> (ResultCode, Row?)` - - `query(_ sql: String, binding: Binding, rows type: Row.Type = Row.self) -> (ResultCode, [Row])` - - Strictness rules: - - No-row overloads fail with `.misuse` if a row is produced. - - Single-row overloads must enforce **0 or 1** row total; if a second row exists, fail with `.misuse`. - - Binding failure (via `Statement.bind(_:)`) fails with `.misuse`. - - Row decoding failure (via `Statement.row(_:)`) fails with `.misuse`. - - Underlying SQLite result codes from stepping propagate (for example `.busy`, `.error`). - - `Statement` is left reusable: always `reset()` before returning; binding overloads also `clearBindings()` (at least on success, ideally on all paths). - - Implementation notes: - - Add `Statement+Query.swift` in `Sources/LSQLiteExtensions` with a small internal helper that runs the statement and (optionally) collects/validates rows. - - Add `Connection+Query.swift` in `Sources/LSQLiteExtensions` that prepares exactly one statement, delegates to `Statement.query`, and finalizes via `defer`. - - If the SQL string contains multiple statements, only the first prepared statement is executed; any remaining SQL (including comments) is ignored. - - Performance: for row-returning overloads, build/validate the result column name map once per query (not per row) and reuse it while stepping; similarly, avoid recomputing the statement parameter map multiple times within a single query call. - - Tests (Swift Testing): - - `Tests/LSQLiteExtensionsTests/Statement+QueryTests.swift` (`@Suite("Statement+Query")`) - - `Tests/LSQLiteExtensionsTests/Connection+QueryTests.swift` (`@Suite("Connection+Query")`) - - Cover: `.misuse` on >1 row for single-row overloads, `.misuse` when using no-row overloads on a row-producing statement, and statement reusability across multiple calls. - -4. Transactions & savepoints - - Add thin helpers for explicit transaction control and savepoints. - - `TransactionMode` with `deferred` / `immediate` / `exclusive` behavior. - - `SavepointName` for typed savepoint identifiers. - - `Connection.beginTransaction(_:)`, `commitTransaction()`, `rollbackTransaction()`. - - `Connection.savepoint(_:)`, `releaseSavepoint(_:)`, `rollbackToSavepoint(_:)` (quote identifiers). - - Add a closure-based helper that uses savepoints when already inside a transaction. - - `Connection.transaction(_:_:)` uses `isAutocommit` to choose `BEGIN` vs `SAVEPOINT`. - - Commit/release when body returns `.ok` or `.done`; rollback otherwise. - - Use `exec(_:)` for SQL that includes multiple statements. - - Tests (Swift Testing): - - `Tests/LSQLiteExtensionsTests/Connection+TransactionTests.swift` (`@Suite("Connection+Transaction")`) - - Cover autocommit toggling and nested savepoint behavior. - -5. Pragmas - - Read/write helpers for commonly used pragmas and convenience around pragma-related queries. - - Approach: typed-only APIs. Do not add a public generic “run arbitrary PRAGMA by name” API. - - Implementation notes: - - Add `Sources/LSQLiteExtensions/Connection+Pragma.swift`. - - Implement a tiny private helper that prepares exactly one PRAGMA statement via `Statement.prepare`, steps, reads column 0, enforces **0 or 1** row total, and finalizes via `defer`. - - Return `.misuse` when the PRAGMA produces more than one row, when the value type does not match the expected Swift type, or when a scalar PRAGMA unexpectedly returns no rows. - - Keep SQLite semantics: propagate `ResultCode` from prepare/step; do not introduce throwing flows. - - Avoid string interpolation of PRAGMA names. Each public method hardcodes its PRAGMA SQL; if a database name must be accepted, quote it as an identifier. - - Initial surface (examples; keep the set small and additive): - - `user_version` (`Int32` round-trip). - - `application_id` (`Int32` round-trip). - - `foreign_keys` (`Bool` read/write). - - Tests (Swift Testing): - - `Tests/LSQLiteExtensionsTests/Connection+PragmaTests.swift` (`@Suite("Connection+Pragma")`), using an in-memory database and asserting round-trips and `.misuse` strictness. - ---- above are implemented --- - -6. Introspection & system tables - - Helpers for working with `sqlite_schema` / `sqlite_master` and schema discovery (tables/views/indexes/triggers). - - Helpers for pragma-driven metadata (table info, index info, foreign keys, etc). - -7. Migrations - - Versioning helpers (e.g. `user_version`) and a migration runner built on the previous features. - -8. WAL & checkpoint - - Convenience APIs around WAL checkpointing and related WAL controls. - -## Testing -- Swift Testing suites per feature file, using in-memory databases and asserting via `ResultCode`. - -## Documentation & examples -- Public docs for new symbols (with “Related SQLite:” lists), plus README recipes per feature.