From e428430918155654027eace5d9c4fd4e77482075 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Wed, 21 Jan 2026 18:50:00 -0500 Subject: [PATCH 01/10] Add traits for SQLite compile options --- Package.swift | 320 ++++++++++++++++-- Package@swift-5.9.swift | 78 +++++ Sources/SQLiteDB/Core/Blob.swift | 2 +- Sources/SQLiteDB/Core/SQLiteVersion.swift | 4 +- Sources/SQLiteDB/Core/Value.swift | 6 +- Sources/SQLiteDB/Extensions/FTS4.swift | 2 +- Sources/SQLiteDB/Foundation.swift | 2 +- .../SQLiteDB/Schema/SchemaDefinitions.swift | 16 +- Sources/SQLiteDB/Schema/SchemaReader.swift | 2 +- Sources/SQLiteDB/Typed/Expression.swift | 2 +- Sources/SQLiteDB/Typed/Query+with.swift | 6 +- Sources/SQLiteDB/Typed/Query.swift | 8 +- .../SQLiteDBTests/Core/ConnectionTests.swift | 12 +- Tests/SQLiteDBTests/TestHelpers.swift | 2 +- .../Typed/CustomAggregationTests.swift | 8 +- 15 files changed, 397 insertions(+), 73 deletions(-) create mode 100644 Package@swift-5.9.swift diff --git a/Package.swift b/Package.swift index 4ccc111..c934070 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,113 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.1 import PackageDescription +/// Compile-time options +/// - seealso: [Recommended Compile-time Options](https://sqlite.org/compile.html#recommended_compile_time_options) +let compileTimeOptions: [CSetting] = [ + // https://sqlite.org/compile.html#dqs + .define("SQLITE_DQS", to: "0", .when(traits: ["DQS_0"])), + .define("SQLITE_DQS", to: "1", .when(traits: ["DQS_1"])), + .define("SQLITE_DQS", to: "2", .when(traits: ["DQS_2"])), + .define("SQLITE_DQS", to: "3", .when(traits: ["DQS_3"])), + // https://sqlite.org/compile.html#threadsafe + .define("SQLITE_THREADSAFE", to: "0", .when(traits: ["THREADSAFE_0"])), + .define("SQLITE_THREADSAFE", to: "1", .when(traits: ["THREADSAFE_1"])), + .define("SQLITE_THREADSAFE", to: "2", .when(traits: ["THREADSAFE_2"])), + // https://sqlite.org/compile.html#default_memstatus + .define("SQLITE_DEFAULT_MEMSTATUS", to: "0", .when(traits: ["DEFAULT_MEMSTATUS_0"])), + // https://sqlite.org/compile.html#default_wal_synchronous + .define("SQLITE_DEFAULT_WAL_SYNCHRONOUS", to: "1", .when(traits: ["DEFAULT_WAL_SYNCHRONOUS_1"])), + // https://sqlite.org/compile.html#like_doesnt_match_blobs + .define("SQLITE_LIKE_DOESNT_MATCH_BLOBS", .when(traits: ["LIKE_DOESNT_MATCH_BLOBS"])), + // https://sqlite.org/limits.html#max_expr_depth + .define("SQLITE_MAX_EXPR_DEPTH", to: "0", .when(traits: ["MAX_EXPR_DEPTH_0"])), + // https://sqlite.org/compile.html#omit_decltype + .define("SQLITE_OMIT_DECLTYPE", .when(traits: ["OMIT_DECLTYPE"])), + // https://sqlite.org/compile.html#omit_deprecated + .define("SQLITE_OMIT_DEPRECATED", .when(traits: ["OMIT_DEPRECATED"])), + // https://sqlite.org/compile.html#omit_progress_callback + .define("SQLITE_OMIT_PROGRESS_CALLBACK", .when(traits: ["OMIT_PROGRESS_CALLBACK"])), + // https://sqlite.org/compile.html#omit_shared_cache + .define("SQLITE_OMIT_SHARED_CACHE", .when(traits: ["OMIT_SHARED_CACHE"])), + // https://sqlite.org/compile.html#use_alloca + .define("SQLITE_USE_ALLOCA", .when(traits: ["USE_ALLOCA"])), + // https://sqlite.org/compile.html#omit_autoinit + .define("SQLITE_OMIT_AUTOINIT", .when(traits: ["OMIT_AUTOINIT"])), + // https://sqlite.org/compile.html#strict_subtype + .define("SQLITE_STRICT_SUBTYPE", to: "1", .when(traits: ["STRICT_SUBTYPE_1"])), +] + +/// Platform configuration +/// - seealso: [Platform Configuration](https://sqlite.org/compile.html#_platform_configuration) +let platformConfiguration: [CSetting] = [ + .define("HAVE_ISNAN", to: "1"), + // 'strchrnul' is available on linux since glibc 2.1.1 (1999-05-24) + .define("HAVE_STRCHRNUL", to: "1", .when(platforms: [.linux])), + // 'strchrnul' is only available on macOS 15.4, iOS 18.4, tvOS 18.4, watchOS 11.4 + // and there is no way to specify a version in the build settings condition platform + .define("HAVE_UTIME", to: "1"), +] + +/// Features +/// - seealso: [Options To Enable Features Normally Turned Off](https://sqlite.org/compile.html#_options_to_enable_features_normally_turned_off) +let features: [CSetting] = [ + // https://sqlite.org/bytecodevtab.html + .define("SQLITE_ENABLE_BYTECODE_VTAB", .when(traits: ["ENABLE_BYTECODE_VTAB"])), + // https://sqlite.org/carray.html + .define("SQLITE_ENABLE_CARRAY", .when(traits: ["ENABLE_CARRAY"])), + // https://sqlite.org/c3ref/column_database_name.html + .define("SQLITE_ENABLE_COLUMN_METADATA", .when(traits: ["ENABLE_COLUMN_METADATA"])), + // https://sqlite.org/dbpage.html + .define("SQLITE_ENABLE_DBPAGE_VTAB", .when(traits: ["ENABLE_DBPAGE_VTAB"])), + // https://sqlite.org/dbstat.html + .define("SQLITE_ENABLE_DBSTAT_VTAB", .when(traits: ["ENABLE_DBSTAT_VTAB"])), + // https://sqlite.org/fts3.html + .define("SQLITE_ENABLE_FTS4", .when(traits: ["ENABLE_FTS4"])), + // https://sqlite.org/fts5.html + .define("SQLITE_ENABLE_FTS5", .when(traits: ["ENABLE_FTS5"])), + // https://sqlite.org/geopoly.html + .define("SQLITE_ENABLE_GEOPOLY", .when(traits: ["ENABLE_GEOPOLY"])), + //.define("SQLITE_ENABLE_ICU", .when(traits: ["ENABLE_ICU"])), + // https://sqlite.org/lang_mathfunc.html + .define("SQLITE_ENABLE_MATH_FUNCTIONS", .when(traits: ["ENABLE_MATH_FUNCTIONS"])), + // https://sqlite.org/c3ref/expanded_sql.html + .define("SQLITE_ENABLE_NORMALIZE", .when(traits: ["ENABLE_NORMALIZE"])), + // https://sqlite.org/percentile.html + .define("SQLITE_ENABLE_PERCENTILE", .when(traits: ["ENABLE_PERCENTILE"])), + // https://sqlite.org/c3ref/preupdate_blobwrite.html + .define("SQLITE_ENABLE_PREUPDATE_HOOK", .when(traits: ["ENABLE_PREUPDATE_HOOK"])), + // https://sqlite.org/rtree.html + .define("SQLITE_ENABLE_RTREE", .when(traits: ["ENABLE_RTREE"])), + // https://sqlite.org/sessionintro.html + .define("SQLITE_ENABLE_SESSION", .when(traits: ["ENABLE_SESSION"])), + // https://sqlite.org/c3ref/snapshot.html + .define("SQLITE_ENABLE_SNAPSHOT", .when(traits: ["ENABLE_SNAPSHOT"])), + // https://sqlite.org/stmt.html + .define("SQLITE_ENABLE_STMTVTAB", .when(traits: ["ENABLE_STMTVTAB"])), + // https://sqlite.org/fileformat2.html#stat4tab + .define("SQLITE_ENABLE_STAT4", .when(traits: ["ENABLE_STAT4"])), +] + +let sqlcipherConfiguration: [CSetting] = [ + .headerSearchPath("libtomcrypt/headers"), + .define("SQLITE_ENABLE_API_ARMOR"), + .define("SQLITE_ENABLE_MEMORY_MANAGEMENT"), + .define("SQLITE_ENABLE_UNKNOWN_SQL_FUNCTION"), + .define("SQLITE_ENABLE_UNLOCK_NOTIFY"), + .define("SQLITE_MAX_VARIABLE_NUMBER", to: "250000"), + .define("SQLITE_SECURE_DELETE"), + .define("SQLITE_USE_URI"), + .define("SQLITE_HAS_CODEC"), + .define("SQLITE_HOMEGROWN_RECURSIVE_MUTEX"), // needed or we see hangs in test cases + .define("SQLITE_TEMP_STORE", to: "2"), + .define("SQLITE_EXTRA_INIT", to: "sqlcipher_extra_init"), + .define("SQLITE_EXTRA_SHUTDOWN", to: "sqlcipher_extra_shutdown"), + .define("HAVE_GETHOSTUUID", to: "0"), + .define("HAVE_STDINT_H"), + .define("SQLCIPHER_CRYPTO_LIBTOMCRYPT"), + .define("SQLCIPHER_CRYPTO_CUSTOM", to: "sqlcipher_ltc_setup"), +] + let package = Package( name: "swift-sqlcipher", platforms: [ @@ -20,6 +127,179 @@ let package = Package( targets: ["SQLCipher"] ) ], + traits: [ + // Compile-time options + .trait( + name: "DQS_0", + description: "Disallow double-quoted string literals in DDL and DML" + ), + .trait( + name: "DQS_1", + description: "Disallow double-quoted strings in DDL, allow in DML" + ), + .trait( + name: "DQS_2", + description: "Allow double-quoted strings in DDL, disallow in DML" + ), + .trait( + name: "DQS_3", + description: "Allow double-quoted strings in DDL and DML" + ), + .trait( + name: "THREADSAFE_0", + description: "Omit all mutex and thread-safety logic" + ), + .trait( + name: "THREADSAFE_1", + description: "The library may be safely used from multiple threads (serialized mode)" + ), + .trait( + name: "THREADSAFE_2", + description: "The library may be safely used from multiple threads but individual database connections can only be used by a single thread at a time (multi-thread mode)" + ), + .trait( + name: "DEFAULT_MEMSTATUS_0", + description: "Disable memory allocation statistics by default" + ), + .trait( + name: "DEFAULT_WAL_SYNCHRONOUS_1", + description: "Use synchronous=NORMAL in WAL mode by default" + ), + .trait( + name: "LIKE_DOESNT_MATCH_BLOBS", + description: "Don't allow BLOB operands to LIKE and GLOB operators" + ), + .trait( + name: "MAX_EXPR_DEPTH_0", + description: "Disable all checking of the expression parse-tree depth" + ), + .trait( + name: "OMIT_DECLTYPE", + description: "Omit the ability to return the declared type of columns" + ), + .trait( + name: "OMIT_DEPRECATED", + description: "Omit deprecated interfaces and features" + ), + .trait( + name: "OMIT_PROGRESS_CALLBACK", + description: "Omit progress callback" + ), + .trait( + name: "OMIT_SHARED_CACHE", + description: "Omit shared cache support" + ), + .trait( + name: "USE_ALLOCA", + description: "Use alloca to dynamically allocate temporary stack space" + ), + .trait( + name: "OMIT_AUTOINIT", + description: "Omit automatic library initialization" + ), + .trait( + name: "STRICT_SUBTYPE_1", + description: "Cause application-defined SQL functions not registered with the SQLITE_RESULT_SUBTYPE property to raise an error when invoking sqlite3_result_subtype" + ), + // Features + .trait( + name: "ENABLE_BYTECODE_VTAB", + description: "Enables bytecode and tables_used table-valued functions" + ), + .trait( + name: "ENABLE_CARRAY", + description: "Enables the carray extension" + ), + .trait( + name: "ENABLE_COLUMN_METADATA", + description: "Enables column and table metadata functions" + ), + .trait( + name: "ENABLE_DBPAGE_VTAB", + description: "Enables the sqlite_dbpage virtual table" + ), + .trait( + name: "ENABLE_DBSTAT_VTAB", + description: "Enables the dbstat virtual table" + ), + .trait( + name: "ENABLE_FTS4", + description: "Enables versions 3 and 4 of the full-text search engine (fts3 and fts4)" + ), + .trait( + name: "ENABLE_FTS5", + description: "Enables version 5 of the full-text search engine (fts5)" + ), + .trait( + name: "ENABLE_GEOPOLY", + description: "Enables the Geopoly extension" + ), + .trait( + name: "ENABLE_MATH_FUNCTIONS", + description: "Enables the built-in SQL math functions" + ), + .trait( + name: "ENABLE_NORMALIZE", + description: "Enables the sqlite3_normalized_sql function" + ), + .trait( + name: "ENABLE_PERCENTILE", + description: "Enables the percentile extension" + ), + .trait( + name: "ENABLE_PREUPDATE_HOOK", + description: "Enables the pre-update hook" + ), + .trait( + name: "ENABLE_RTREE", + description: "Enables the R*Tree index extension" + ), + .trait( + name: "ENABLE_SESSION", + description: "Enables the pre-update hook and session extension", + enabledTraits: [ + "ENABLE_PREUPDATE_HOOK", + ] + ), + .trait( + name: "ENABLE_SNAPSHOT", + description: "Enables support for database snapshots" + ), + .trait( + name: "ENABLE_STMTVTAB", + description: "Enables the sqlite_stmt virtual table" + ), + .trait( + name: "ENABLE_STAT4", + description: "Enables the sqlite_stat4 table" + ), + // Default traits + .default(enabledTraits: [ + "DQS_0", + "ENABLE_COLUMN_METADATA", + "ENABLE_DBSTAT_VTAB", + "ENABLE_SESSION", + "ENABLE_PREUPDATE_HOOK", + "THREADSAFE_2", + "DEFAULT_MEMSTATUS_0", + "DEFAULT_WAL_SYNCHRONOUS_1", + "LIKE_DOESNT_MATCH_BLOBS", + "MAX_EXPR_DEPTH_0", + "OMIT_DEPRECATED", + "OMIT_PROGRESS_CALLBACK", + "OMIT_SHARED_CACHE", + "USE_ALLOCA", + "STRICT_SUBTYPE_1", + "ENABLE_CARRAY", + "ENABLE_FTS5", + "ENABLE_MATH_FUNCTIONS", + "ENABLE_PERCENTILE", + "ENABLE_RTREE", + "ENABLE_SNAPSHOT", + "ENABLE_STMTVTAB", + "ENABLE_STAT4", + ]), + ], targets: [ .target( name: "SQLiteDB", @@ -31,42 +311,8 @@ let package = Package( name: "SQLCipher", sources: ["sqlite", "libtomcrypt"], publicHeadersPath: "sqlite", - cSettings: [ - .headerSearchPath("libtomcrypt/headers"), - .define("SQLITE_DQS", to: "0"), - .define("SQLITE_ENABLE_API_ARMOR"), - .define("SQLITE_ENABLE_COLUMN_METADATA"), - .define("SQLITE_ENABLE_DBSTAT_VTAB"), - .define("SQLITE_ENABLE_FTS3"), - .define("SQLITE_ENABLE_FTS3_PARENTHESIS"), - .define("SQLITE_ENABLE_FTS3_TOKENIZER"), - .define("SQLITE_ENABLE_FTS4"), - .define("SQLITE_ENABLE_FTS5"), - .define("SQLITE_ENABLE_MEMORY_MANAGEMENT"), - .define("SQLITE_ENABLE_PREUPDATE_HOOK"), - .define("SQLITE_ENABLE_RTREE"), - .define("SQLITE_ENABLE_SESSION"), - .define("SQLITE_ENABLE_STMTVTAB"), - .define("SQLITE_ENABLE_UNKNOWN_SQL_FUNCTION"), - .define("SQLITE_ENABLE_UNLOCK_NOTIFY"), - .define("SQLITE_MAX_VARIABLE_NUMBER", to: "250000"), - .define("SQLITE_LIKE_DOESNT_MATCH_BLOBS"), - .define("SQLITE_OMIT_DEPRECATED"), - .define("SQLITE_OMIT_SHARED_CACHE"), - .define("SQLITE_SECURE_DELETE"), - .define("SQLITE_THREADSAFE", to: "2"), - .define("SQLITE_USE_URI"), - .define("SQLITE_ENABLE_SNAPSHOT"), - .define("SQLITE_HAS_CODEC"), - .define("SQLITE_HOMEGROWN_RECURSIVE_MUTEX"), // needed or we see hangs in test cases - .define("SQLITE_TEMP_STORE", to: "2"), - .define("SQLITE_EXTRA_INIT", to: "sqlcipher_extra_init"), - .define("SQLITE_EXTRA_SHUTDOWN", to: "sqlcipher_extra_shutdown"), - .define("HAVE_GETHOSTUUID", to: "0"), - .define("HAVE_STDINT_H"), - .define("SQLCIPHER_CRYPTO_LIBTOMCRYPT"), - .define("SQLCIPHER_CRYPTO_CUSTOM", to: "sqlcipher_ltc_setup"), - ], + cSettings: + compileTimeOptions + platformConfiguration + features + sqlcipherConfiguration, linkerSettings: [.linkedLibrary("log", .when(platforms: [.android]))]), .testTarget( name: "SQLiteDBTests", diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..4ccc111 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,78 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "swift-sqlcipher", + platforms: [ + .iOS(.v13), + .macOS(.v10_13), + .watchOS(.v4), + .tvOS(.v12), + .visionOS(.v1) + ], + products: [ + .library( + name: "SQLiteDB", + targets: ["SQLiteDB"] + ), + .library( + name: "SQLCipher", + targets: ["SQLCipher"] + ) + ], + targets: [ + .target( + name: "SQLiteDB", + dependencies: [.target(name: "SQLCipher")], + cSettings: [.define("SQLITE_HAS_CODEC")], + swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] + ), + .target( + name: "SQLCipher", + sources: ["sqlite", "libtomcrypt"], + publicHeadersPath: "sqlite", + cSettings: [ + .headerSearchPath("libtomcrypt/headers"), + .define("SQLITE_DQS", to: "0"), + .define("SQLITE_ENABLE_API_ARMOR"), + .define("SQLITE_ENABLE_COLUMN_METADATA"), + .define("SQLITE_ENABLE_DBSTAT_VTAB"), + .define("SQLITE_ENABLE_FTS3"), + .define("SQLITE_ENABLE_FTS3_PARENTHESIS"), + .define("SQLITE_ENABLE_FTS3_TOKENIZER"), + .define("SQLITE_ENABLE_FTS4"), + .define("SQLITE_ENABLE_FTS5"), + .define("SQLITE_ENABLE_MEMORY_MANAGEMENT"), + .define("SQLITE_ENABLE_PREUPDATE_HOOK"), + .define("SQLITE_ENABLE_RTREE"), + .define("SQLITE_ENABLE_SESSION"), + .define("SQLITE_ENABLE_STMTVTAB"), + .define("SQLITE_ENABLE_UNKNOWN_SQL_FUNCTION"), + .define("SQLITE_ENABLE_UNLOCK_NOTIFY"), + .define("SQLITE_MAX_VARIABLE_NUMBER", to: "250000"), + .define("SQLITE_LIKE_DOESNT_MATCH_BLOBS"), + .define("SQLITE_OMIT_DEPRECATED"), + .define("SQLITE_OMIT_SHARED_CACHE"), + .define("SQLITE_SECURE_DELETE"), + .define("SQLITE_THREADSAFE", to: "2"), + .define("SQLITE_USE_URI"), + .define("SQLITE_ENABLE_SNAPSHOT"), + .define("SQLITE_HAS_CODEC"), + .define("SQLITE_HOMEGROWN_RECURSIVE_MUTEX"), // needed or we see hangs in test cases + .define("SQLITE_TEMP_STORE", to: "2"), + .define("SQLITE_EXTRA_INIT", to: "sqlcipher_extra_init"), + .define("SQLITE_EXTRA_SHUTDOWN", to: "sqlcipher_extra_shutdown"), + .define("HAVE_GETHOSTUUID", to: "0"), + .define("HAVE_STDINT_H"), + .define("SQLCIPHER_CRYPTO_LIBTOMCRYPT"), + .define("SQLCIPHER_CRYPTO_CUSTOM", to: "sqlcipher_ltc_setup"), + ], + linkerSettings: [.linkedLibrary("log", .when(platforms: [.android]))]), + .testTarget( + name: "SQLiteDBTests", + dependencies: ["SQLiteDB"], + resources: [.process("Resources")], + swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] + ) + ] +) diff --git a/Sources/SQLiteDB/Core/Blob.swift b/Sources/SQLiteDB/Core/Blob.swift index a709fb4..6bf8f3a 100644 --- a/Sources/SQLiteDB/Core/Blob.swift +++ b/Sources/SQLiteDB/Core/Blob.swift @@ -22,7 +22,7 @@ // THE SOFTWARE. // -public struct Blob { +public struct Blob: Sendable { public let bytes: [UInt8] diff --git a/Sources/SQLiteDB/Core/SQLiteVersion.swift b/Sources/SQLiteDB/Core/SQLiteVersion.swift index fa7358d..7ae4aa5 100644 --- a/Sources/SQLiteDB/Core/SQLiteVersion.swift +++ b/Sources/SQLiteDB/Core/SQLiteVersion.swift @@ -1,6 +1,6 @@ import Foundation -public struct SQLiteVersion: Comparable, CustomStringConvertible { +public struct SQLiteVersion: Comparable, CustomStringConvertible, Sendable { public let major: Int public let minor: Int public var point: Int = 0 @@ -17,6 +17,6 @@ public struct SQLiteVersion: Comparable, CustomStringConvertible { lhs.tuple == rhs.tuple } - static var zero: SQLiteVersion = .init(major: 0, minor: 0) + static let zero: SQLiteVersion = .init(major: 0, minor: 0) private var tuple: (Int, Int, Int) { (major, minor, point) } } diff --git a/Sources/SQLiteDB/Core/Value.swift b/Sources/SQLiteDB/Core/Value.swift index 249a772..f0a77aa 100644 --- a/Sources/SQLiteDB/Core/Value.swift +++ b/Sources/SQLiteDB/Core/Value.swift @@ -27,7 +27,7 @@ /// /// Do not conform custom types to the Binding protocol. See the `Value` /// protocol, instead. -public protocol Binding {} +public protocol Binding: Sendable {} public protocol Number: Binding {} @@ -105,7 +105,7 @@ extension Blob: Binding, Value { extension Bool: Binding, Value { - public static var declaredDatatype = Int64.declaredDatatype + public static let declaredDatatype = Int64.declaredDatatype public static func fromDatatypeValue(_ datatypeValue: Int64) -> Bool { datatypeValue != 0 @@ -119,7 +119,7 @@ extension Bool: Binding, Value { extension Int: Number, Value { - public static var declaredDatatype = Int64.declaredDatatype + public static let declaredDatatype = Int64.declaredDatatype public static func fromDatatypeValue(_ datatypeValue: Int64) -> Int { Int(datatypeValue) diff --git a/Sources/SQLiteDB/Extensions/FTS4.swift b/Sources/SQLiteDB/Extensions/FTS4.swift index 3fbe454..e651cc9 100644 --- a/Sources/SQLiteDB/Extensions/FTS4.swift +++ b/Sources/SQLiteDB/Extensions/FTS4.swift @@ -88,7 +88,7 @@ extension VirtualTable { } // swiftlint:disable identifier_name -public struct Tokenizer { +public struct Tokenizer: Sendable { public static let Simple = Tokenizer("simple") public static let Porter = Tokenizer("porter") diff --git a/Sources/SQLiteDB/Foundation.swift b/Sources/SQLiteDB/Foundation.swift index 44a3173..eb2946b 100644 --- a/Sources/SQLiteDB/Foundation.swift +++ b/Sources/SQLiteDB/Foundation.swift @@ -61,7 +61,7 @@ extension Date: Value { /// A global date formatter used to serialize and deserialize `NSDate` objects. /// If multiple date formats are used in an application’s database(s), use a /// custom `Value` type per additional format. -public var dateFormatter: DateFormatter = { +public let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" formatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/Sources/SQLiteDB/Schema/SchemaDefinitions.swift b/Sources/SQLiteDB/Schema/SchemaDefinitions.swift index 80f9e19..9ff5b48 100644 --- a/Sources/SQLiteDB/Schema/SchemaDefinitions.swift +++ b/Sources/SQLiteDB/Schema/SchemaDefinitions.swift @@ -39,13 +39,13 @@ public struct ObjectDefinition: Equatable { // https://sqlite.org/syntax/column-def.html // column-name -> type-name -> column-constraint* -public struct ColumnDefinition: Equatable { +public struct ColumnDefinition: Equatable, Sendable { // The type affinity of a column is the recommended type for data stored in that column. // The important idea here is that the type is recommended, not required. Any column can still // store any type of data. It is just that some columns, given the choice, will prefer to use one // storage class over another. The preferred storage class for a column is called its "affinity". - public enum Affinity: String, CustomStringConvertible, CaseIterable { + public enum Affinity: String, CustomStringConvertible, CaseIterable, Sendable { case INTEGER case NUMERIC case REAL @@ -73,7 +73,7 @@ public struct ColumnDefinition: Equatable { } } - public enum OnConflict: String, CaseIterable { + public enum OnConflict: String, CaseIterable, Sendable { case ROLLBACK case ABORT case FAIL @@ -86,7 +86,7 @@ public struct ColumnDefinition: Equatable { } } - public struct PrimaryKey: Equatable { + public struct PrimaryKey: Equatable, Sendable { let autoIncrement: Bool let onConflict: OnConflict? @@ -116,7 +116,7 @@ public struct ColumnDefinition: Equatable { } } - public struct ForeignKey: Equatable { + public struct ForeignKey: Equatable, Sendable { let table: String let column: String let primaryKey: String? @@ -151,7 +151,7 @@ public struct ColumnDefinition: Equatable { } } -public enum LiteralValue: Equatable, CustomStringConvertible { +public enum LiteralValue: Equatable, CustomStringConvertible, Sendable { // swiftlint:disable force_try private static let singleQuote = try! NSRegularExpression(pattern: "^'(.*)'$") private static let doubleQuote = try! NSRegularExpression(pattern: "^\"(.*)\"$") @@ -233,7 +233,7 @@ public enum LiteralValue: Equatable, CustomStringConvertible { // https://sqlite.org/lang_createindex.html // schema-name.index-name ON table-name ( indexed-column+ ) WHERE expr -public struct IndexDefinition: Equatable { +public struct IndexDefinition: Equatable, Sendable { // SQLite supports index names up to 64 characters. static let maxIndexLength = 64 @@ -242,7 +242,7 @@ public struct IndexDefinition: Equatable { static let orderRe = try! NSRegularExpression(pattern: "\"?(\\w+)\"? DESC") // swiftlint:enable force_try - public enum Order: String { case ASC, DESC } + public enum Order: String, Sendable { case ASC, DESC } public init(table: String, name: String, unique: Bool = false, columns: [String], `where`: String? = nil, orders: [String: Order]? = nil) { self.table = table diff --git a/Sources/SQLiteDB/Schema/SchemaReader.swift b/Sources/SQLiteDB/Schema/SchemaReader.swift index f14704c..b858176 100644 --- a/Sources/SQLiteDB/Schema/SchemaReader.swift +++ b/Sources/SQLiteDB/Schema/SchemaReader.swift @@ -125,7 +125,7 @@ public class SchemaReader { } } -private enum SchemaTable { +private enum SchemaTable: Sendable { private static let name = Table("sqlite_schema", database: "main") private static let tempName = Table("sqlite_schema", database: "temp") // legacy names (< 3.33.0) diff --git a/Sources/SQLiteDB/Typed/Expression.swift b/Sources/SQLiteDB/Typed/Expression.swift index e599d6b..26f477c 100644 --- a/Sources/SQLiteDB/Typed/Expression.swift +++ b/Sources/SQLiteDB/Typed/Expression.swift @@ -70,7 +70,7 @@ public struct SQLExpression: ExpressionType { } -public protocol Expressible { +public protocol Expressible: Sendable { var expression: SQLExpression { get } diff --git a/Sources/SQLiteDB/Typed/Query+with.swift b/Sources/SQLiteDB/Typed/Query+with.swift index a87ce59..e4ec46b 100644 --- a/Sources/SQLiteDB/Typed/Query+with.swift +++ b/Sources/SQLiteDB/Typed/Query+with.swift @@ -95,15 +95,15 @@ extension QueryType { } /// Materialization hints for `WITH` clause -public enum MaterializationHint: String { +public enum MaterializationHint: String, Sendable { case materialized = "MATERIALIZED" case notMaterialized = "NOT MATERIALIZED" } -struct WithClauses { - struct Clause { +struct WithClauses: Sendable { + struct Clause: Sendable { var alias: Table var columns: [Expressible]? var hint: MaterializationHint? diff --git a/Sources/SQLiteDB/Typed/Query.swift b/Sources/SQLiteDB/Typed/Query.swift index 3d50ca9..79c95af 100644 --- a/Sources/SQLiteDB/Typed/Query.swift +++ b/Sources/SQLiteDB/Typed/Query.swift @@ -1168,7 +1168,7 @@ extension Connection { } -public struct Row { +public struct Row: Sendable { let columnNames: [String: Int] @@ -1242,7 +1242,7 @@ public struct Row { } /// Determines the join operator for a query’s `JOIN` clause. -public enum JoinType: String { +public enum JoinType: String, Sendable { /// A `CROSS` join. case cross = "CROSS" @@ -1256,7 +1256,7 @@ public enum JoinType: String { } /// ON CONFLICT resolutions. -public enum OnConflict: String { +public enum OnConflict: String, Sendable { case replace = "REPLACE" @@ -1272,7 +1272,7 @@ public enum OnConflict: String { // MARK: - Private -public struct QueryClauses { +public struct QueryClauses: Sendable { var select = (distinct: false, columns: [SQLExpression(literal: "*") as Expressible]) diff --git a/Tests/SQLiteDBTests/Core/ConnectionTests.swift b/Tests/SQLiteDBTests/Core/ConnectionTests.swift index c15bf79..688c01c 100644 --- a/Tests/SQLiteDBTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteDBTests/Core/ConnectionTests.swift @@ -292,7 +292,7 @@ class ConnectionTests: SQLiteTestCase { assertSQL("RELEASE SAVEPOINT '1'", 0) } - func test_updateHook_setsUpdateHook_withInsert() throws { + @MainActor func test_updateHook_setsUpdateHook_withInsert() throws { try async { done in db.updateHook { operation, db, table, rowid in XCTAssertEqual(Connection.Operation.insert, operation) @@ -305,7 +305,7 @@ class ConnectionTests: SQLiteTestCase { } } - func test_updateHook_setsUpdateHook_withUpdate() throws { + @MainActor func test_updateHook_setsUpdateHook_withUpdate() throws { try insertUser("alice") try async { done in db.updateHook { operation, db, table, rowid in @@ -319,7 +319,7 @@ class ConnectionTests: SQLiteTestCase { } } - func test_updateHook_setsUpdateHook_withDelete() throws { + @MainActor func test_updateHook_setsUpdateHook_withDelete() throws { try insertUser("alice") try async { done in db.updateHook { operation, db, table, rowid in @@ -333,7 +333,7 @@ class ConnectionTests: SQLiteTestCase { } } - func test_commitHook_setsCommitHook() throws { + @MainActor func test_commitHook_setsCommitHook() throws { try async { done in db.commitHook { done() @@ -345,7 +345,7 @@ class ConnectionTests: SQLiteTestCase { } } - func test_rollbackHook_setsRollbackHook() throws { + @MainActor func test_rollbackHook_setsRollbackHook() throws { try async { done in db.rollbackHook(done) do { @@ -359,7 +359,7 @@ class ConnectionTests: SQLiteTestCase { } } - func test_commitHook_withRollback_rollsBack() throws { + @MainActor func test_commitHook_withRollback_rollsBack() throws { try async { done in db.commitHook { throw NSError(domain: "com.stephencelis.SQLiteTests", code: 1, userInfo: nil) diff --git a/Tests/SQLiteDBTests/TestHelpers.swift b/Tests/SQLiteDBTests/TestHelpers.swift index 4989a37..a6f48e3 100644 --- a/Tests/SQLiteDBTests/TestHelpers.swift +++ b/Tests/SQLiteDBTests/TestHelpers.swift @@ -66,7 +66,7 @@ class SQLiteTestCase: XCTestCase { // if let count = trace[SQL] { trace[SQL] = count - 1 } // } - func async(expect description: String = "async", timeout: Double = 5, block: (@escaping () -> Void) throws -> Void) throws { + @MainActor func async(expect description: String = "async", timeout: Double = 5, block: (@escaping () -> Void) throws -> Void) throws { let expectation = self.expectation(description: description) try block({ expectation.fulfill() }) waitForExpectations(timeout: timeout, handler: nil) diff --git a/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift b/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift index dee2ced..e299f60 100644 --- a/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift +++ b/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift @@ -147,11 +147,11 @@ class CustomAggregationTests: SQLiteTestCase { /// This class is used to test that aggregation state variables /// can be reference types and are properly memory managed when /// crossing the Swift<->C boundary multiple times. -class TestObject { - static var inits = 0 - static var deinits = 0 +final class TestObject: @unsafe Sendable { + nonisolated(unsafe) static var inits = 0 + nonisolated(unsafe) static var deinits = 0 - var value: Int64 + nonisolated(unsafe) var value: Int64 init(value: Int64) { self.value = value TestObject.inits += 1 From e25108c2c85e4c0d7a1c1a038bd8fe972a44c44f Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Wed, 21 Jan 2026 20:45:48 -0500 Subject: [PATCH 02/10] Make more types Sendable --- Sources/SQLiteDB/Core/URIQueryParameter.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteDB/Core/URIQueryParameter.swift b/Sources/SQLiteDB/Core/URIQueryParameter.swift index abbab2e..25f303e 100644 --- a/Sources/SQLiteDB/Core/URIQueryParameter.swift +++ b/Sources/SQLiteDB/Core/URIQueryParameter.swift @@ -1,12 +1,12 @@ import Foundation /// See https://www.sqlite.org/uri.html -public enum URIQueryParameter: CustomStringConvertible { - public enum FileMode: String { +public enum URIQueryParameter: CustomStringConvertible, Sendable { + public enum FileMode: String, Sendable { case readOnly = "ro", readWrite = "rw", readWriteCreate = "rwc", memory } - public enum CacheMode: String { + public enum CacheMode: String, Sendable { case shared, `private` } From 7e6a06de36c40e99e8b219bfd0e439a5cb1cc841 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 22 Jan 2026 14:17:14 -0500 Subject: [PATCH 03/10] Wrap Connection in an @unchecked Sendable for test_concurrent_access_single_connection --- Tests/SQLiteDBTests/Core/ConnectionTests.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Tests/SQLiteDBTests/Core/ConnectionTests.swift b/Tests/SQLiteDBTests/Core/ConnectionTests.swift index 688c01c..dbaa6be 100644 --- a/Tests/SQLiteDBTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteDBTests/Core/ConnectionTests.swift @@ -427,20 +427,26 @@ class ConnectionTests: SQLiteTestCase { #endif func test_concurrent_access_single_connection() throws { - // test can fail on iOS/tvOS 9.x: SQLite compile-time differences? - guard #available(iOS 10.0, OSX 10.10, tvOS 10.0, watchOS 2.2, *) else { return } - let conn = try Connection("\(NSTemporaryDirectory())/\(UUID().uuidString)") try conn.execute("DROP TABLE IF EXISTS test; CREATE TABLE test(value);") try conn.run("INSERT INTO test(value) VALUES(?)", 0) let queue = DispatchQueue(label: "Readers", attributes: [.concurrent]) + struct ConnectionWrapper: @unchecked Sendable { + let conn: Connection + + func callScalar() { + _ = try! conn.scalar("SELECT value FROM test") + } + } + + let wrapper = ConnectionWrapper(conn: conn) let nReaders = 5 let semaphores = Array(repeating: DispatchSemaphore(value: 100), count: nReaders) for index in 0.. Date: Thu, 22 Jan 2026 14:22:36 -0500 Subject: [PATCH 04/10] --- Package.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index c934070..ef48fbf 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.1 +// swift-tools-version:6.2 import PackageDescription /// Compile-time options @@ -305,7 +305,14 @@ let package = Package( name: "SQLiteDB", dependencies: [.target(name: "SQLCipher")], cSettings: [.define("SQLITE_HAS_CODEC")], - swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] + swiftSettings: [ + .define("SQLITE_SWIFT_SQLCIPHER"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("InferSendableFromCaptures"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") + ] ), .target( name: "SQLCipher", From b65b610489c8c295b4a7f2515b1d767ffe4864ca Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 22 Jan 2026 14:26:46 -0500 Subject: [PATCH 05/10] Remove @unsafe Sendable from TestObject --- Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift b/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift index e299f60..aafba05 100644 --- a/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift +++ b/Tests/SQLiteDBTests/Typed/CustomAggregationTests.swift @@ -147,7 +147,7 @@ class CustomAggregationTests: SQLiteTestCase { /// This class is used to test that aggregation state variables /// can be reference types and are properly memory managed when /// crossing the Swift<->C boundary multiple times. -final class TestObject: @unsafe Sendable { +final class TestObject: Sendable { nonisolated(unsafe) static var inits = 0 nonisolated(unsafe) static var deinits = 0 From 32eeac63c7db70f16e0efcaee54db8219e195200 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Mon, 2 Feb 2026 23:15:34 +0100 Subject: [PATCH 06/10] Update tests and cleanup conditional imports --- Examples/PackageTraits/README.md | 4 - Package.swift | 44 ++- Package@swift-5.9.swift | 6 +- Sources/SQLCipher/sqlite/sqlite3.h | 4 +- Sources/SQLiteDB/Core/Backup.swift | 8 - .../Core/Connection+Aggregation.swift | 8 - Sources/SQLiteDB/Core/Connection+Attach.swift | 15 - Sources/SQLiteDB/Core/Connection.swift | 8 - Sources/SQLiteDB/Core/Result.swift | 8 - Sources/SQLiteDB/Core/Statement.swift | 8 - Sources/SQLiteDB/Extensions/Cipher.swift | 2 - Sources/SQLiteDB/Helpers.swift | 8 - Tests/SQLCipherTests/SQLCipherTests.swift | 320 ++++++++++++++++++ Tests/SQLCipherTests/SQLiteTests.swift | 283 ++++++++++++++++ .../Core/Connection+AttachTests.swift | 8 - .../Core/Connection+PragmaTests.swift | 8 - .../SQLiteDBTests/Core/ConnectionTests.swift | 8 - Tests/SQLiteDBTests/Core/ResultTests.swift | 8 - Tests/SQLiteDBTests/Core/StatementTests.swift | 8 - .../Extensions/CipherTests.swift | 2 - .../Extensions/FTSIntegrationTests.swift | 8 - .../Typed/CustomAggregationTests.swift | 8 - .../Typed/QueryIntegrationTests.swift | 8 - Tests/SQLiteDBTests/Typed/QueryTests.swift | 8 - sqlcipher-4 | 1 + 25 files changed, 627 insertions(+), 174 deletions(-) create mode 100644 Tests/SQLCipherTests/SQLCipherTests.swift create mode 100644 Tests/SQLCipherTests/SQLiteTests.swift create mode 100644 sqlcipher-4 diff --git a/Examples/PackageTraits/README.md b/Examples/PackageTraits/README.md index f4ae42b..21727ce 100644 --- a/Examples/PackageTraits/README.md +++ b/Examples/PackageTraits/README.md @@ -64,11 +64,7 @@ let package = Package( The `SQLMiddleware` module has just a single top-level function `middlewareDatabaseType()` that will return either "SQLCipher " or "SQLite3 " depending on whether it was included with the "SQLCipher" trait. ```swift - #if canImport(SQLCipher) import SQLCipher - #else - import SQLite3 - #endif public func databaseVersion() -> String? { #if canImport(SQLCipher) diff --git a/Package.swift b/Package.swift index ef48fbf..e319e5a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.2 +// swift-tools-version:6.1 import PackageDescription /// Compile-time options @@ -90,6 +90,11 @@ let features: [CSetting] = [ let sqlcipherConfiguration: [CSetting] = [ .headerSearchPath("libtomcrypt/headers"), + .define("SQLITE_HAS_CODEC"), + .define("SQLCIPHER_CRYPTO_LIBTOMCRYPT"), + .define("SQLCIPHER_CRYPTO_CUSTOM", to: "sqlcipher_ltc_setup"), + .define("SQLITE_EXTRA_INIT", to: "sqlcipher_extra_init"), + .define("SQLITE_EXTRA_SHUTDOWN", to: "sqlcipher_extra_shutdown"), .define("SQLITE_ENABLE_API_ARMOR"), .define("SQLITE_ENABLE_MEMORY_MANAGEMENT"), .define("SQLITE_ENABLE_UNKNOWN_SQL_FUNCTION"), @@ -97,15 +102,10 @@ let sqlcipherConfiguration: [CSetting] = [ .define("SQLITE_MAX_VARIABLE_NUMBER", to: "250000"), .define("SQLITE_SECURE_DELETE"), .define("SQLITE_USE_URI"), - .define("SQLITE_HAS_CODEC"), .define("SQLITE_HOMEGROWN_RECURSIVE_MUTEX"), // needed or we see hangs in test cases .define("SQLITE_TEMP_STORE", to: "2"), - .define("SQLITE_EXTRA_INIT", to: "sqlcipher_extra_init"), - .define("SQLITE_EXTRA_SHUTDOWN", to: "sqlcipher_extra_shutdown"), .define("HAVE_GETHOSTUUID", to: "0"), .define("HAVE_STDINT_H"), - .define("SQLCIPHER_CRYPTO_LIBTOMCRYPT"), - .define("SQLCIPHER_CRYPTO_CUSTOM", to: "sqlcipher_ltc_setup"), ] let package = Package( @@ -280,7 +280,7 @@ let package = Package( "ENABLE_DBSTAT_VTAB", "ENABLE_SESSION", "ENABLE_PREUPDATE_HOOK", - "THREADSAFE_2", + "THREADSAFE_1", "DEFAULT_MEMSTATUS_0", "DEFAULT_WAL_SYNCHRONOUS_1", "LIKE_DOESNT_MATCH_BLOBS", @@ -301,31 +301,25 @@ let package = Package( ]), ], targets: [ - .target( - name: "SQLiteDB", - dependencies: [.target(name: "SQLCipher")], - cSettings: [.define("SQLITE_HAS_CODEC")], - swiftSettings: [ - .define("SQLITE_SWIFT_SQLCIPHER"), - .enableUpcomingFeature("DisableOutwardActorInference"), - .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), - .enableUpcomingFeature("InferIsolatedConformances"), - .enableUpcomingFeature("InferSendableFromCaptures"), - .enableUpcomingFeature("NonisolatedNonsendingByDefault") - ] - ), .target( name: "SQLCipher", sources: ["sqlite", "libtomcrypt"], publicHeadersPath: "sqlite", - cSettings: - compileTimeOptions + platformConfiguration + features + sqlcipherConfiguration, + cSettings: compileTimeOptions + platformConfiguration + features + sqlcipherConfiguration, linkerSettings: [.linkedLibrary("log", .when(platforms: [.android]))]), + .testTarget( + name: "SQLCipherTests", + dependencies: ["SQLCipher"] + ), + .target( + name: "SQLiteDB", + dependencies: [.target(name: "SQLCipher")], + cSettings: [.define("SQLITE_HAS_CODEC")] + ), .testTarget( name: "SQLiteDBTests", dependencies: ["SQLiteDB"], - resources: [.process("Resources")], - swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] - ) + resources: [.process("Resources")] + ), ] ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 4ccc111..dfa9f4c 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -24,8 +24,7 @@ let package = Package( .target( name: "SQLiteDB", dependencies: [.target(name: "SQLCipher")], - cSettings: [.define("SQLITE_HAS_CODEC")], - swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] + cSettings: [.define("SQLITE_HAS_CODEC")] ), .target( name: "SQLCipher", @@ -71,8 +70,7 @@ let package = Package( .testTarget( name: "SQLiteDBTests", dependencies: ["SQLiteDB"], - resources: [.process("Resources")], - swiftSettings: [.define("SQLITE_SWIFT_SQLCIPHER")] + resources: [.process("Resources")] ) ] ) diff --git a/Sources/SQLCipher/sqlite/sqlite3.h b/Sources/SQLCipher/sqlite/sqlite3.h index 6e548d8..fbe66f8 100644 --- a/Sources/SQLCipher/sqlite/sqlite3.h +++ b/Sources/SQLCipher/sqlite/sqlite3.h @@ -6661,7 +6661,7 @@ SQLITE_API int sqlite3_collation_needed16( ); /* BEGIN SQLCIPHER */ -#ifdef SQLITE_HAS_CODEC +//#ifdef SQLITE_HAS_CODEC /* ** Specify the key for an encrypted database. This routine should be ** called right after sqlite3_open(). @@ -6717,7 +6717,7 @@ SQLITE_API int sqlite3_rekey_v2( SQLITE_API void sqlite3_activate_see( const char *zPassPhrase /* Activation phrase */ ); -#endif +//#endif /* END SQLCIPHER */ #ifdef SQLITE_ENABLE_CEROD diff --git a/Sources/SQLiteDB/Core/Backup.swift b/Sources/SQLiteDB/Core/Backup.swift index 3f54555..7909181 100644 --- a/Sources/SQLiteDB/Core/Backup.swift +++ b/Sources/SQLiteDB/Core/Backup.swift @@ -24,15 +24,7 @@ import Foundation import Dispatch -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif /// An object representing database backup. /// diff --git a/Sources/SQLiteDB/Core/Connection+Aggregation.swift b/Sources/SQLiteDB/Core/Connection+Aggregation.swift index 63a93b1..8eb0caf 100644 --- a/Sources/SQLiteDB/Core/Connection+Aggregation.swift +++ b/Sources/SQLiteDB/Core/Connection+Aggregation.swift @@ -1,13 +1,5 @@ import Foundation -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif extension Connection { private typealias Aggregate = @convention(block) (Int, Context, Int32, Argv) -> Void diff --git a/Sources/SQLiteDB/Core/Connection+Attach.swift b/Sources/SQLiteDB/Core/Connection+Attach.swift index 5a51e61..fa64077 100644 --- a/Sources/SQLiteDB/Core/Connection+Attach.swift +++ b/Sources/SQLiteDB/Core/Connection+Attach.swift @@ -1,16 +1,7 @@ import Foundation -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif extension Connection { - #if SQLITE_SWIFT_SQLCIPHER /// See https://www.zetetic.net/sqlcipher/sqlcipher-api/#attach public func attach(_ location: Location, as schemaName: String, key: String? = nil) throws { if let key { @@ -19,12 +10,6 @@ extension Connection { try run("ATTACH DATABASE ? AS ?", location.description, schemaName) } } - #else - /// See https://www3.sqlite.org/lang_attach.html - public func attach(_ location: Location, as schemaName: String) throws { - try run("ATTACH DATABASE ? AS ?", location.description, schemaName) - } - #endif /// See https://www3.sqlite.org/lang_detach.html public func detach(_ schemaName: String) throws { diff --git a/Sources/SQLiteDB/Core/Connection.swift b/Sources/SQLiteDB/Core/Connection.swift index d640cfb..c32008f 100644 --- a/Sources/SQLiteDB/Core/Connection.swift +++ b/Sources/SQLiteDB/Core/Connection.swift @@ -24,15 +24,7 @@ import Foundation import Dispatch -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif /// A connection to SQLite. public final class Connection { diff --git a/Sources/SQLiteDB/Core/Result.swift b/Sources/SQLiteDB/Core/Result.swift index 888976f..c1c6577 100644 --- a/Sources/SQLiteDB/Core/Result.swift +++ b/Sources/SQLiteDB/Core/Result.swift @@ -1,12 +1,4 @@ -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif public enum Result: Error { diff --git a/Sources/SQLiteDB/Core/Statement.swift b/Sources/SQLiteDB/Core/Statement.swift index 973dfc0..cb4d345 100644 --- a/Sources/SQLiteDB/Core/Statement.swift +++ b/Sources/SQLiteDB/Core/Statement.swift @@ -22,15 +22,7 @@ // THE SOFTWARE. // -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif /// A single SQL statement. public final class Statement { diff --git a/Sources/SQLiteDB/Extensions/Cipher.swift b/Sources/SQLiteDB/Extensions/Cipher.swift index 54aee15..49676de 100644 --- a/Sources/SQLiteDB/Extensions/Cipher.swift +++ b/Sources/SQLiteDB/Extensions/Cipher.swift @@ -1,4 +1,3 @@ -#if SQLITE_SWIFT_SQLCIPHER import SQLCipher @@ -112,4 +111,3 @@ extension Connection { _ = try scalar("SELECT count(*) FROM sqlite_master;") } } -#endif diff --git a/Sources/SQLiteDB/Helpers.swift b/Sources/SQLiteDB/Helpers.swift index 0a7b3d0..1333e1c 100644 --- a/Sources/SQLiteDB/Helpers.swift +++ b/Sources/SQLiteDB/Helpers.swift @@ -22,15 +22,7 @@ // THE SOFTWARE. // -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif public typealias Star = (SQLExpression?, SQLExpression?) -> SQLExpression diff --git a/Tests/SQLCipherTests/SQLCipherTests.swift b/Tests/SQLCipherTests/SQLCipherTests.swift new file mode 100644 index 0000000..a9cff2b --- /dev/null +++ b/Tests/SQLCipherTests/SQLCipherTests.swift @@ -0,0 +1,320 @@ +import Testing +import Foundation +import SQLCipher + +@Suite("SQLCipher Cryptographic Integrity") +struct SQLCipherTests { + + let dbPath: String = { + let temp = NSTemporaryDirectory() + return (temp as NSString).appendingPathComponent("encrypted-\(UUID().uuidString).db") + }() + + // Helper to cleanup file between tests + func cleanup() { + try? FileManager.default.removeItem(atPath: dbPath) + } + + // MARK: - Keying & Encryption Tests + + @Test("Successful encryption with valid key") + func testSuccessfulEncryption() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + #expect(sqlite3_open(dbPath, &db) == SQLITE_OK) + + // Apply key + let key = "password123" + #expect(sqlite3_key(db, key, Int32(key.count)) == SQLITE_OK) + + // Create table to force header creation + #expect(sqlite3_exec(db, "CREATE TABLE secret (data TEXT);", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(db, "INSERT INTO secret VALUES ('sensitive');", nil, nil, nil) == SQLITE_OK) + + sqlite3_close(db) + } + + @Test("Accessing encrypted DB without key fails") + func testAccessWithoutKey() throws { + cleanup() + defer { cleanup() } + + // 1. Create encrypted DB + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "key", 3) + sqlite3_exec(db, "CREATE TABLE t1(x);", nil, nil, nil) + sqlite3_close(db) + + // 2. Try to open without key + var db2: OpaquePointer? + sqlite3_open(dbPath, &db2) + // SQLCipher returns SQLITE_NOTADB or SQLITE_IOERR if the header can't be decrypted + let result = sqlite3_exec(db2, "SELECT count(*) FROM t1;", nil, nil, nil) + #expect(result == SQLITE_NOTADB) + sqlite3_close(db2) + } + + @Test("Accessing with wrong key fails") + func testWrongKey() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "right-key", 9) + sqlite3_exec(db, "CREATE TABLE t1(x);", nil, nil, nil) + sqlite3_close(db) + + var db2: OpaquePointer? + sqlite3_open(dbPath, &db2) + sqlite3_key(db2, "wrong-key", 9) + let result = sqlite3_exec(db2, "SELECT * FROM t1;", nil, nil, nil) + #expect(result == SQLITE_NOTADB) + sqlite3_close(db2) + } + + @Test("PRAGMA cipher_version verification") + func testCipherVersion() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "PRAGMA cipher_version;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + let version = String(cString: sqlite3_column_text(stmt, 0)) + #expect(!version.isEmpty) // Ensures SQLCipher is actually linked + sqlite3_finalize(stmt) + } + + // MARK: - Cipher Configuration + + @Test("Custom PBKDF2 Iterations") + func testCustomIterations() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "key", 3) + // High iteration count for testing + sqlite3_exec(db, "PRAGMA kdf_iter = 100000;", nil, nil, nil) + #expect(sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) == SQLITE_OK) + sqlite3_close(db) + } + + @Test("Cipher Page Size adjustment") + func testPageSize() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + + #expect(sqlite3_exec(db, "PRAGMA cipher_page_size = 4096;", nil, nil, nil) == SQLITE_OK) + } + + // MARK: - Rekeying (Key Migration) + + @Test("Changing the database key (rekey)") + func testRekey() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "old", 3) + sqlite3_exec(db, "CREATE TABLE t1(x);", nil, nil, nil) + + // Change key + #expect(sqlite3_rekey(db, "new", 3) == SQLITE_OK) + sqlite3_close(db) + + // Verify with new key + var db2: OpaquePointer? + sqlite3_open(dbPath, &db2) + #expect(sqlite3_key(db2, "new", 3) == SQLITE_OK) + #expect(sqlite3_exec(db2, "SELECT * FROM t1;", nil, nil, nil) == SQLITE_OK) + sqlite3_close(db2) + } + + // MARK: - Migration & Compatibility + + @Test("Migrating from SQLCipher 3 to 4") + func testCipherMigration() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + sqlite3_key(db, "key", 3) + + // This pragma attempts to upgrade a database if it uses older salt/settings + let result = sqlite3_exec(db, "PRAGMA cipher_migrate;", nil, nil, nil) + #expect([SQLITE_OK, SQLITE_DONE].contains(result)) + } + + @Test("Using Raw Key (Hex)") + func testRawHexKey() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + + // SQLCipher allows 64-character hex strings prefixed with x' + let hexKey = "x'6162636465666768696A6B6C6D6E6F706162636465666768696A6B6C6D6E6F70'" + #expect(sqlite3_exec(db, "PRAGMA key = \"\(hexKey)\";", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) == SQLITE_OK) + } + + // MARK: - Advanced Cryptographic Features + + @Test("Memory zeroing on close") + func testMemorySanitizer() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + sqlite3_key(db, "key", 3) + // SQLCipher automatically zeroes out key material in memory on close + #expect(sqlite3_close(db) == SQLITE_OK) + } + + @Test("HMAC Check integrity") + func testHMACCheck() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + sqlite3_key(db, "key", 3) + + // Ensure HMAC is enabled (default in v4) + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "PRAGMA cipher_use_hmac;", -1, &stmt, nil) + sqlite3_step(stmt) + #expect(sqlite3_column_int(stmt, 0) == 1) + sqlite3_finalize(stmt) + } + + @Test("Cipher Salt extraction") + func testSaltExtraction() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "key", 3) + sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) + + var stmt: OpaquePointer? + // This retrieves the 16-byte salt from the DB header + sqlite3_prepare_v2(db, "PRAGMA cipher_salt;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + let salt = sqlite3_column_blob(stmt, 0) + #expect(salt != nil) + sqlite3_finalize(stmt) + sqlite3_close(db) + } + + // MARK: - ATTACH Operations (Cross-DB Encryption) + + @Test("Attaching encrypted DB to another") + func testAttachEncrypted() throws { + cleanup() + defer { cleanup() } + + var mainDB: OpaquePointer? + sqlite3_open(dbPath, &mainDB) + sqlite3_key(mainDB, "key1", 4) + + // Create second DB + let path2 = dbPath + "2" + var db2: OpaquePointer? + sqlite3_open(path2, &db2) + sqlite3_key(db2, "key2", 4) + sqlite3_exec(db2, "CREATE TABLE t2(y);", nil, nil, nil) + sqlite3_close(db2) + + // Attach DB2 to MainDB + let sql = "ATTACH DATABASE '\(path2)' AS db2 KEY 'key2';" + #expect(sqlite3_exec(mainDB, sql, nil, nil, nil) == SQLITE_OK) + + sqlite3_close(mainDB) + try? FileManager.default.removeItem(atPath: path2) + } + + @Test("Exporting Plaintext to Encrypted") + func testExportToEncrypted() throws { + let plainPath = dbPath + ".plain" + var db: OpaquePointer? + sqlite3_open(plainPath, &db) + sqlite3_exec(db, "CREATE TABLE t(x); INSERT INTO t VALUES (1);", nil, nil, nil) + + // Attach a new encrypted DB and export + sqlite3_exec(db, "ATTACH DATABASE '\(dbPath)' AS encrypted KEY 'secret';", nil, nil, nil) + #expect(sqlite3_exec(db, "SELECT sqlcipher_export('encrypted');", nil, nil, nil) == SQLITE_OK) + sqlite3_exec(db, "DETACH DATABASE encrypted;", nil, nil, nil) + + sqlite3_close(db) + try? FileManager.default.removeItem(atPath: plainPath) + } + + + // MARK: - Error Handling & Edge Cases + + @Test("Empty key behavior") + func testEmptyKey() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + // SQLCipher requires a non-empty key for encryption usually, + // otherwise it acts as a standard SQLite DB. + #expect(sqlite3_key(db, "", 0) == SQLITE_MISUSE) + #expect(sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) == SQLITE_OK) + } + + @Test("Rekey on in-memory database") + func testInMemoryRekey() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + sqlite3_key(db, "k1", 2) + sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) + #expect(sqlite3_rekey(db, "k2", 2) == SQLITE_OK) + #expect(sqlite3_exec(db, "SELECT * FROM t;", nil, nil, nil) == SQLITE_OK) + } + + @Test("Cipher Settings: plaintext_header_size") + func testPlaintextHeader() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + // Keeping some of the header unencrypted (e.g. for identification) + #expect(sqlite3_exec(db, "PRAGMA plaintext_header_size = 16;", nil, nil, nil) == SQLITE_OK) + } + + @Test("Cipher Profile selection") + func testCipherProfile() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + defer { sqlite3_close(db) } + // Testing forced profile compatibility (SQLCipher 4 default vs 3) + #expect(sqlite3_exec(db, "PRAGMA cipher_profile = 'sqlcipher-4';", nil, nil, nil) == SQLITE_OK) + } + + @Test("Verify file header after encryption") + func testHeaderValidation() throws { + cleanup() + defer { cleanup() } + + var db: OpaquePointer? + sqlite3_open(dbPath, &db) + sqlite3_key(db, "key", 3) + sqlite3_exec(db, "CREATE TABLE t(x);", nil, nil, nil) + sqlite3_close(db) + + // Read the first 16 bytes. Standard SQLite starts with "SQLite format 3" + // SQLCipher headers are randomized/encrypted and should NOT start with that string. + let handle = FileHandle(forReadingAtPath: dbPath) + let header = handle?.readData(ofLength: 15) + let headerString = String(data: header ?? Data(), encoding: .ascii) + + #expect(headerString != "SQLite format 3") + try? handle?.close() + } +} diff --git a/Tests/SQLCipherTests/SQLiteTests.swift b/Tests/SQLCipherTests/SQLiteTests.swift new file mode 100644 index 0000000..fefd700 --- /dev/null +++ b/Tests/SQLCipherTests/SQLiteTests.swift @@ -0,0 +1,283 @@ +import Testing +import Foundation +import SQLCipher + +@Suite("SQLite Core Operations & Concurrency") +struct SQLLiteTests { + + // Helper to create an in-memory database + func openDB() throws -> OpaquePointer { + var db: OpaquePointer? + if sqlite3_open(":memory:", &db) != SQLITE_OK { + throw AppError.connectionFailed + } + return db! + } + + enum AppError: Error { + case connectionFailed, executionFailed + } + + @Test("Create table and verify schema") + func testCreateTable() throws { + let db = try openDB() + defer { sqlite3_close(db) } + + let sql = "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);" + let result = sqlite3_exec(db, sql, nil, nil, nil) + #expect(result == SQLITE_OK) + } + + @Test("Insert and fetch single row") + func testInsertAndFetch() throws { + let db = try openDB() + defer { sqlite3_close(db) } + + sqlite3_exec(db, "CREATE TABLE items (val TEXT);", nil, nil, nil) + sqlite3_exec(db, "INSERT INTO items (val) VALUES ('Swift');", nil, nil, nil) + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "SELECT val FROM items;", -1, &stmt, nil) + defer { sqlite3_finalize(stmt) } + + #expect(sqlite3_step(stmt) == SQLITE_ROW) + let text = String(cString: sqlite3_column_text(stmt, 0)) + #expect(text == "Swift") + } + + @Test("Handle Null values correctly") + func testNullHandling() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE data (val TEXT); INSERT INTO data (val) VALUES (NULL);", nil, nil, nil) + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "SELECT val FROM data;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + #expect(sqlite3_column_type(stmt, 0) == SQLITE_NULL) + sqlite3_finalize(stmt) + } + + @Test("Transaction Rollback on error") + func testRollback() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE balance (amt INTEGER); INSERT INTO balance VALUES (100);", nil, nil, nil) + + sqlite3_exec(db, "BEGIN TRANSACTION;", nil, nil, nil) + sqlite3_exec(db, "UPDATE balance SET amt = 200;", nil, nil, nil) + sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "SELECT amt FROM balance;", -1, &stmt, nil) + sqlite3_step(stmt) + #expect(sqlite3_column_int(stmt, 0) == 100) + sqlite3_finalize(stmt) + } + + @Test("Large Blob storage and retrieval") + func testBlobStorage() throws { + let db = try openDB() + defer { sqlite3_close(db) } + let data = "binary_data".data(using: .utf8)! + + sqlite3_exec(db, "CREATE TABLE blobs (b BLOB);", nil, nil, nil) + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "INSERT INTO blobs (b) VALUES (?);", -1, &stmt, nil) + + _ = data.withUnsafeBytes { ptr in + sqlite3_bind_blob(stmt, 1, ptr.baseAddress, Int32(data.count), nil) + } + #expect(sqlite3_step(stmt) == SQLITE_DONE) + sqlite3_finalize(stmt) + } + + @Test("Parameter binding protection (SQL Injection)") + func testBinding() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE logs (msg TEXT);", nil, nil, nil) + + let maliciousInput = "'); DROP TABLE logs; --" + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "INSERT INTO logs (msg) VALUES (?);", -1, &stmt, nil) + sqlite3_bind_text(stmt, 1, maliciousInput, -1, nil) + + #expect(sqlite3_step(stmt) == SQLITE_DONE) + sqlite3_finalize(stmt) + // Verify table still exists + #expect(sqlite3_exec(db, "SELECT * FROM logs;", nil, nil, nil) == SQLITE_OK) + } + + @Test("Update row count verification") + func testChangesCount() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1), (2), (3);", nil, nil, nil) + sqlite3_exec(db, "UPDATE t SET id = id + 10;", nil, nil, nil) + #expect(sqlite3_changes(db) == 3) + } + + @Test("Data integrity with UNIQUE constraint") + func testUniqueConstraint() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE u (id INTEGER UNIQUE);", nil, nil, nil) + sqlite3_exec(db, "INSERT INTO u VALUES (1);", nil, nil, nil) + let result = sqlite3_exec(db, "INSERT INTO u VALUES (1);", nil, nil, nil) + #expect(result == SQLITE_CONSTRAINT) + } + + @Test("Primary Key Autoincrement") + func testAutoincrement() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE a (id INTEGER PRIMARY KEY AUTOINCREMENT, n TEXT);", nil, nil, nil) + sqlite3_exec(db, "INSERT INTO a (n) VALUES ('A');", nil, nil, nil) + sqlite3_exec(db, "INSERT INTO a (n) VALUES ('B');", nil, nil, nil) + #expect(sqlite3_last_insert_rowid(db) == 2) + } + + @Test("Complex Join correctness") + func testJoins() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE t1 (id INT, v TEXT); CREATE TABLE t2 (id INT, v TEXT);", nil, nil, nil) + sqlite3_exec(db, "INSERT INTO t1 VALUES (1, 'A'); INSERT INTO t2 VALUES (1, 'B');", nil, nil, nil) + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "SELECT t1.v, t2.v FROM t1 JOIN t2 ON t1.id = t2.id;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + #expect(String(cString: sqlite3_column_text(stmt, 0)) == "A") + #expect(String(cString: sqlite3_column_text(stmt, 1)) == "B") + sqlite3_finalize(stmt) + } + + @Test("Concurrent Reads from multiple connections") + func testConcurrentReads() async throws { + // Shared file path for multi-connection tests + let path = NSTemporaryDirectory() + "test_concurrent.db" + sqlite3_open(path, nil) // Create it + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<5 { + group.addTask { + var db: OpaquePointer? + sqlite3_open(path, &db) + let result = sqlite3_exec(db, "SELECT 1;", nil, nil, nil) + #expect(result == SQLITE_OK) + sqlite3_close(db) + } + } + } + } + + @Test("Busy handler retry logic") + func testBusyHandler() throws { + let db = try openDB() + defer { sqlite3_close(db) } + // Setting a timeout for 1000ms + let result = sqlite3_busy_timeout(db, 1000) + #expect(result == SQLITE_OK) + } + + @Test("WAL Mode enablement") + func testWALMode() throws { + let path = NSTemporaryDirectory() + "wal_test.db" + var db: OpaquePointer? + sqlite3_open(path, &db) + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + let mode = String(cString: sqlite3_column_text(stmt, 0)) + #expect(mode.lowercased() == "wal") + sqlite3_finalize(stmt) + } + + @Test("Concurrent Write contention (Expected Busy)") + func testWriteContention() async throws { + let path = NSTemporaryDirectory() + "contention.db" + var db1: OpaquePointer? + sqlite3_open(path, &db1) + sqlite3_exec(db1, "CREATE TABLE sync_test (id INT);", nil, nil, nil) + + // Start a transaction on connection 1 + sqlite3_exec(db1, "BEGIN EXCLUSIVE;", nil, nil, nil) + + let task = Task { + var db2: OpaquePointer? + sqlite3_open(path, &db2) + // This should fail or timeout because db1 has an exclusive lock + let res = sqlite3_exec(db2, "INSERT INTO sync_test VALUES (1);", nil, nil, nil) + sqlite3_close(db2) + return res + } + + let result = await task.value + #expect(result == SQLITE_BUSY) + sqlite3_exec(db1, "COMMIT;", nil, nil, nil) + sqlite3_close(db1) + } + + @Test("Prepared statement re-use in loops") + func testStatementReuse() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "CREATE TABLE loop (id INT);", nil, nil, nil) + + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "INSERT INTO loop VALUES (?);", -1, &stmt, nil) + + for i in 1...5 { + sqlite3_bind_int(stmt, 1, Int32(i)) + #expect(sqlite3_step(stmt) == SQLITE_DONE) + sqlite3_reset(stmt) // Essential for reuse + } + sqlite3_finalize(stmt) + } + + @Test("Thread-safe library configuration") + func testThreadSafeMode() { + let mode = sqlite3_threadsafe() + // 1 = Serialized, 2 = Multi-thread + #expect(mode > 0) + } + + @Test("Memory management (Double close protection)") + func testDoubleClose() throws { + var db: OpaquePointer? + sqlite3_open(":memory:", &db) + #expect(sqlite3_close(db) == SQLITE_OK) + // Subsequent calls should not crash, though result may vary by OS + #expect(sqlite3_close(db) == SQLITE_MISUSE || sqlite3_close(db) == SQLITE_OK) + } + + @Test("Recursive Trigger limits") + func testTriggers() throws { + let db = try openDB() + defer { sqlite3_close(db) } + sqlite3_exec(db, "PRAGMA recursive_triggers = ON;", nil, nil, nil) + let sql = """ + CREATE TABLE t(x); + CREATE TRIGGER r AFTER INSERT ON t BEGIN INSERT INTO t VALUES(new.x+1); END; + """ + sqlite3_exec(db, sql, nil, nil, nil) + // This will eventually hit the trigger depth limit + let res = sqlite3_exec(db, "INSERT INTO t VALUES(1);", nil, nil, nil) + #expect(res == SQLITE_ERROR) + } + + @Test("Database Corruption check (Integrity Check)") + func testIntegrity() throws { + let db = try openDB() + defer { sqlite3_close(db) } + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, "PRAGMA integrity_check;", -1, &stmt, nil) + #expect(sqlite3_step(stmt) == SQLITE_ROW) + let status = String(cString: sqlite3_column_text(stmt, 0)) + #expect(status == "ok") + sqlite3_finalize(stmt) + } +} diff --git a/Tests/SQLiteDBTests/Core/Connection+AttachTests.swift b/Tests/SQLiteDBTests/Core/Connection+AttachTests.swift index 818cec8..71ad8d0 100644 --- a/Tests/SQLiteDBTests/Core/Connection+AttachTests.swift +++ b/Tests/SQLiteDBTests/Core/Connection+AttachTests.swift @@ -2,15 +2,7 @@ import XCTest import Foundation @testable import SQLiteDB -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif class ConnectionAttachTests: SQLiteTestCase { func test_attach_detach_memory_database() throws { diff --git a/Tests/SQLiteDBTests/Core/Connection+PragmaTests.swift b/Tests/SQLiteDBTests/Core/Connection+PragmaTests.swift index cc2cd17..58c4c3d 100644 --- a/Tests/SQLiteDBTests/Core/Connection+PragmaTests.swift +++ b/Tests/SQLiteDBTests/Core/Connection+PragmaTests.swift @@ -2,15 +2,7 @@ import XCTest import Foundation @testable import SQLiteDB -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif class ConnectionPragmaTests: SQLiteTestCase { func test_userVersion() { diff --git a/Tests/SQLiteDBTests/Core/ConnectionTests.swift b/Tests/SQLiteDBTests/Core/ConnectionTests.swift index dbaa6be..5121435 100644 --- a/Tests/SQLiteDBTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteDBTests/Core/ConnectionTests.swift @@ -3,15 +3,7 @@ import Foundation import Dispatch @testable import SQLiteDB -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif class ConnectionTests: SQLiteTestCase { diff --git a/Tests/SQLiteDBTests/Core/ResultTests.swift b/Tests/SQLiteDBTests/Core/ResultTests.swift index 85a9be2..2025555 100644 --- a/Tests/SQLiteDBTests/Core/ResultTests.swift +++ b/Tests/SQLiteDBTests/Core/ResultTests.swift @@ -2,15 +2,7 @@ import XCTest import Foundation @testable import SQLiteDB -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif class ResultTests: XCTestCase { var connection: Connection! diff --git a/Tests/SQLiteDBTests/Core/StatementTests.swift b/Tests/SQLiteDBTests/Core/StatementTests.swift index b068dbb..90e0ce9 100644 --- a/Tests/SQLiteDBTests/Core/StatementTests.swift +++ b/Tests/SQLiteDBTests/Core/StatementTests.swift @@ -1,15 +1,7 @@ import XCTest @testable import SQLiteDB -#if SQLITE_SWIFT_STANDALONE -import sqlite3 -#elseif SQLITE_SWIFT_SQLCIPHER import SQLCipher -#elseif os(Linux) || os(Windows) || os(Android) -import CSQLite -#else -import SQLite3 -#endif class StatementTests: SQLiteTestCase { override func setUpWithError() throws { diff --git a/Tests/SQLiteDBTests/Extensions/CipherTests.swift b/Tests/SQLiteDBTests/Extensions/CipherTests.swift index 39e5b9d..64016c1 100644 --- a/Tests/SQLiteDBTests/Extensions/CipherTests.swift +++ b/Tests/SQLiteDBTests/Extensions/CipherTests.swift @@ -1,4 +1,3 @@ -#if SQLITE_SWIFT_SQLCIPHER import XCTest import SQLiteDB import SQLCipher @@ -115,4 +114,3 @@ class CipherTests: XCTestCase { Data((0.. Date: Tue, 3 Feb 2026 07:32:12 +0100 Subject: [PATCH 07/10] Add NonisolatedNonsendingByDefault --- Package.swift | 5 ++++- Tests/SQLCipherTests/SQLCipherTests.swift | 9 --------- sqlcipher-4 | 1 - 3 files changed, 4 insertions(+), 11 deletions(-) delete mode 100644 sqlcipher-4 diff --git a/Package.swift b/Package.swift index e319e5a..db19518 100644 --- a/Package.swift +++ b/Package.swift @@ -314,7 +314,10 @@ let package = Package( .target( name: "SQLiteDB", dependencies: [.target(name: "SQLCipher")], - cSettings: [.define("SQLITE_HAS_CODEC")] + cSettings: [.define("SQLITE_HAS_CODEC")], + swiftSettings: [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault") + ] ), .testTarget( name: "SQLiteDBTests", diff --git a/Tests/SQLCipherTests/SQLCipherTests.swift b/Tests/SQLCipherTests/SQLCipherTests.swift index a9cff2b..2b48967 100644 --- a/Tests/SQLCipherTests/SQLCipherTests.swift +++ b/Tests/SQLCipherTests/SQLCipherTests.swift @@ -288,15 +288,6 @@ struct SQLCipherTests { #expect(sqlite3_exec(db, "PRAGMA plaintext_header_size = 16;", nil, nil, nil) == SQLITE_OK) } - @Test("Cipher Profile selection") - func testCipherProfile() throws { - var db: OpaquePointer? - sqlite3_open(":memory:", &db) - defer { sqlite3_close(db) } - // Testing forced profile compatibility (SQLCipher 4 default vs 3) - #expect(sqlite3_exec(db, "PRAGMA cipher_profile = 'sqlcipher-4';", nil, nil, nil) == SQLITE_OK) - } - @Test("Verify file header after encryption") func testHeaderValidation() throws { cleanup() diff --git a/sqlcipher-4 b/sqlcipher-4 deleted file mode 100644 index b25d448..0000000 --- a/sqlcipher-4 +++ /dev/null @@ -1 +0,0 @@ -Elapsed time:0.000 ms - PRAGMA cipher_profile = 'sqlcipher-4'; From b67982d570dab7971ed2d199a44378ca710e40b9 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 3 Feb 2026 07:36:27 +0100 Subject: [PATCH 08/10] Add GlobalActorIsolatedTypesUsability and InferSendableFromCaptures --- Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index db19518..a225740 100644 --- a/Package.swift +++ b/Package.swift @@ -316,6 +316,10 @@ let package = Package( dependencies: [.target(name: "SQLCipher")], cSettings: [.define("SQLITE_HAS_CODEC")], swiftSettings: [ + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("InferSendableFromCaptures"), .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] ), From 1ffd8d450e87c640c7eb5f4184b323a2f4d2b1d1 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 3 Feb 2026 07:59:27 +0100 Subject: [PATCH 09/10] Change Result error parameter to a String so it can be Sendable --- Package.swift | 4 ---- Sources/SQLiteDB/Core/Result.swift | 8 ++++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Package.swift b/Package.swift index a225740..db19518 100644 --- a/Package.swift +++ b/Package.swift @@ -316,10 +316,6 @@ let package = Package( dependencies: [.target(name: "SQLCipher")], cSettings: [.define("SQLITE_HAS_CODEC")], swiftSettings: [ - .enableUpcomingFeature("DisableOutwardActorInference"), - .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), - .enableUpcomingFeature("InferIsolatedConformances"), - .enableUpcomingFeature("InferSendableFromCaptures"), .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] ), diff --git a/Sources/SQLiteDB/Core/Result.swift b/Sources/SQLiteDB/Core/Result.swift index c1c6577..276627c 100644 --- a/Sources/SQLiteDB/Core/Result.swift +++ b/Sources/SQLiteDB/Core/Result.swift @@ -11,7 +11,7 @@ public enum Result: Error { /// - code: SQLite [error code](https://sqlite.org/rescode.html#primary_result_code_list) /// /// - statement: the statement which produced the error - case error(message: String, code: Int32, statement: Statement?) + case error(message: String, code: Int32, statement: String?) /// Represents a SQLite specific [extended error code] (https://sqlite.org/rescode.html#primary_result_codes_versus_extended_result_codes) /// @@ -20,7 +20,7 @@ public enum Result: Error { /// - extendedCode: SQLite [extended error code](https://sqlite.org/rescode.html#extended_result_code_list) /// /// - statement: the statement which produced the error - case extendedError(message: String, extendedCode: Int32, statement: Statement?) + case extendedError(message: String, extendedCode: Int32, statement: String?) init?(errorCode: Int32, connection: Connection, statement: Statement? = nil) { guard !Result.successCodes.contains(errorCode) else { return nil } @@ -28,12 +28,12 @@ public enum Result: Error { let message = String(cString: sqlite3_errmsg(connection.handle)) guard connection.usesExtendedErrorCodes else { - self = .error(message: message, code: errorCode, statement: statement) + self = .error(message: message, code: errorCode, statement: statement?.description) return } let extendedErrorCode = sqlite3_extended_errcode(connection.handle) - self = .extendedError(message: message, extendedCode: extendedErrorCode, statement: statement) + self = .extendedError(message: message, extendedCode: extendedErrorCode, statement: statement?.description) } } From 74b94b37272ffb87d836ebc260509758b5e83254 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 3 Feb 2026 08:15:44 +0100 Subject: [PATCH 10/10] Make ForeignKeyError Sendable --- Sources/SQLiteDB/Schema/SchemaDefinitions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteDB/Schema/SchemaDefinitions.swift b/Sources/SQLiteDB/Schema/SchemaDefinitions.swift index 9ff5b48..48563e5 100644 --- a/Sources/SQLiteDB/Schema/SchemaDefinitions.swift +++ b/Sources/SQLiteDB/Schema/SchemaDefinitions.swift @@ -304,7 +304,7 @@ public struct IndexDefinition: Equatable, Sendable { } } -public struct ForeignKeyError: CustomStringConvertible { +public struct ForeignKeyError: CustomStringConvertible, Sendable { public let from: String public let rowId: Int64 public let to: String