From c95f62d9cf1c182f0a2e8e9ed2370dc424d0a168 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 30 Aug 2025 12:32:21 -0400 Subject: [PATCH 1/4] Linux support (#17) * Linux support * Add SQLite3 package for Linux * Upate SQLite3 package for Linux * Disable SwiftToolchainCSQLite module * Fix import OSLog for SKIP * Handle condition availability of SQLiteConfiguration.platform * import XCTest * Update test configuration * Remove import of SwiftToolchainCSQLite --- .github/workflows/ci.yml | 1 + Package.swift | 16 +++++++ .../SQLContextTestConfiguration.swift | 8 ++++ Tests/SkipSQLPlusTests/SQLPlusTests.swift | 5 +-- .../SQLContextTestConfiguration.swift | 12 +++++ Tests/SkipSQLTests/SQLContextTests.swift | 44 ++++++++++++++----- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19c3f6..57b2fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,3 +16,4 @@ jobs: uses: skiptools/actions/.github/workflows/skip-framework.yml@v1 with: run-android-native-build: true + runs-on: "['macos-13', 'ubuntu-24.04']" diff --git a/Package.swift b/Package.swift index b54e77a..8addf95 100644 --- a/Package.swift +++ b/Package.swift @@ -78,3 +78,19 @@ let package = Package( plugins: [.plugin(name: "skipstone", package: "skip")]), ] ) + +// TODO: this might be the route to take to add Linux support to SkipSQL itself +#if false +#if os(Linux) || os(Windows) +package.dependencies += [.package(url: "https://github.com/swiftlang/swift-toolchain-sqlite.git", from: "1.0.0")] +package.targets += [ + .target( + name: "SQLite3", + dependencies: [ + .product(name: "SwiftToolchainCSQLite", package: "swift-toolchain-sqlite", condition: .when(platforms: [.linux, .windows, .android])) + ] + ) +] +package.targets[0].dependencies += [.target(name: "SQLite3")] +#endif +#endif diff --git a/Tests/SkipSQLPlusTests/SQLContextTestConfiguration.swift b/Tests/SkipSQLPlusTests/SQLContextTestConfiguration.swift index 143af27..c8db6b9 100644 --- a/Tests/SkipSQLPlusTests/SQLContextTestConfiguration.swift +++ b/Tests/SkipSQLPlusTests/SQLContextTestConfiguration.swift @@ -7,3 +7,11 @@ extension SQLiteConfiguration { /// The shared SQLContextTests uses thus local variable to determine the configuration to use public static let test = SQLiteConfiguration.plus } + +func SQLContextTest(path: String? = nil, flags: SQLContext.OpenFlags? = nil) throws -> SQLContext { + if let path { + return try SQLContext(path: path, flags: flags, configuration: .test) + } else { + return SQLContext(configuration: SQLiteConfiguration.test) + } +} diff --git a/Tests/SkipSQLPlusTests/SQLPlusTests.swift b/Tests/SkipSQLPlusTests/SQLPlusTests.swift index 4759b73..37f7507 100644 --- a/Tests/SkipSQLPlusTests/SQLPlusTests.swift +++ b/Tests/SkipSQLPlusTests/SQLPlusTests.swift @@ -1,14 +1,11 @@ // Copyright 2023–2025 Skip // SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception import XCTest -import OSLog import Foundation import SkipSQL import SkipSQLPlus final class SQLPlusTests: XCTestCase { - let logger: Logger = Logger(subsystem: "skip.sql", category: "SQLPlusTests") - func testSQLPlus() throws { let sqlplus = SQLContext(configuration: .plus) _ = try sqlplus.selectAll(sql: "SELECT 1") @@ -70,7 +67,7 @@ final class SQLPlusTests: XCTestCase { func createDB(key: String?, plaintextHeader: Int? = nil, string: String) throws -> Data { let dbPath = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("db") - logger.log("testEncryption: checking db: \(dbPath.path)") + //logger.log("testEncryption: checking db: \(dbPath.path)") let db = try SQLContext(path: dbPath.path, flags: [.create, .readWrite], configuration: .plus) if let key = key { _ = try db.selectAll(sql: "PRAGMA key = '\(key)'") diff --git a/Tests/SkipSQLTests/SQLContextTestConfiguration.swift b/Tests/SkipSQLTests/SQLContextTestConfiguration.swift index 76bce4e..6bb1975 100644 --- a/Tests/SkipSQLTests/SQLContextTestConfiguration.swift +++ b/Tests/SkipSQLTests/SQLContextTestConfiguration.swift @@ -1,8 +1,20 @@ // Copyright 2023–2025 Skip // SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception import SkipSQL +import XCTest extension SQLiteConfiguration { /// The shared SQLContextTests uses thus local variable to determine the configuration to use public static let test = SQLiteConfiguration.platform } + +func SQLContextTest(path: String? = nil, flags: SQLContext.OpenFlags? = nil) throws -> SQLContext { + #if !canImport(SQLite3) + throw XCTSkip("SQLite is not available on platform") + #endif + if let path { + return try SQLContext(path: path, flags: flags, configuration: .test) + } else { + return SQLContext(configuration: SQLiteConfiguration.test) + } +} diff --git a/Tests/SkipSQLTests/SQLContextTests.swift b/Tests/SkipSQLTests/SQLContextTests.swift index f43fc07..f73645b 100644 --- a/Tests/SkipSQLTests/SQLContextTests.swift +++ b/Tests/SkipSQLTests/SQLContextTests.swift @@ -1,10 +1,32 @@ // Copyright 2023–2025 Skip // SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception import XCTest -import OSLog import Foundation import SkipSQL +#if SKIP || canImport(OSLog) +import OSLog +#else +class Logger { + let subsystem: String + let category: String + + init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + } + + func log(_ string: String) { + print("\(subsystem)/\(category): \(string)") + } + + func info(_ string: String) { + print("\(subsystem)/\(category): \(string)") + } +} + +#endif + /* This test is shared between SkipSQLTests and SkipSQLPlusTests using a symbolic link. @@ -16,7 +38,7 @@ final class SQLContextTests: XCTestCase { let logger: Logger = Logger(subsystem: "skip.sql", category: "SQLiteTests") func testSQLiteTrace() throws { - let ctx = SQLContext(configuration: .test) + let ctx = try SQLContextTest() // track executed SQL statements var sql: [String] = [] ctx.trace { sql.append($0) } @@ -31,7 +53,7 @@ final class SQLContextTests: XCTestCase { } func testSQLiteVersion() throws { - let sqlite = SQLContext(configuration: .test) + let sqlite = try SQLContextTest() sqlite.trace { sql in self.logger.info("SQL: \(sql)") } @@ -43,7 +65,7 @@ final class SQLContextTests: XCTestCase { } func testSQLite() throws { - let sqlite = SQLContext(configuration: .test) + let sqlite = try SQLContextTest() logger.info("connected to SQLite version: \(sqlite.versionNumber)") _ = try sqlite.selectAll(sql: "SELECT 1") @@ -246,7 +268,7 @@ final class SQLContextTests: XCTestCase { } func testDateTypes() throws { - let ctx = SQLContext(configuration: .test) + let ctx = try SQLContextTest() var statements: [String] = [] ctx.trace { sql in self.logger.info("SQL: \(sql)") @@ -307,7 +329,7 @@ final class SQLContextTests: XCTestCase { } func testSQLiteNamedParameters() throws { - let ctx = SQLContext(configuration: .test) + let ctx = try SQLContextTest() do { let stmnt = try ctx.prepare(sql: "SELECT 1") @@ -355,7 +377,7 @@ final class SQLContextTests: XCTestCase { } func testSQLiteInterrupt() async throws { - let ctx = SQLContext(configuration: .test) + let ctx = try SQLContextTest() let stmnt = try ctx.prepare(sql: """ WITH RECURSIVE SlowQuery AS ( @@ -398,7 +420,7 @@ final class SQLContextTests: XCTestCase { func testSQLitePerformance() throws { let dir = URL.temporaryDirectory let dbpath = dir.appendingPathComponent("testSQLitePerformance-\(UUID().uuidString).db").path - let sqlite = try SQLContext(path: dbpath, flags: [.create, .readWrite], configuration: .test) + let sqlite = try SQLContextTest(path: dbpath, flags: [.create, .readWrite]) try sqlite.exec(sql: "CREATE TABLE BIGTABLE (STRING TEXT)") @@ -418,7 +440,7 @@ final class SQLContextTests: XCTestCase { } func testSQLCodable() throws { - let sqlite = SQLContext(configuration: .test) + let sqlite = try SQLContextTest() var statements: [String] = [] sqlite.trace { sql in self.logger.info("SQL: \(sql)") @@ -881,7 +903,7 @@ final class SQLContextTests: XCTestCase { } func testMultipleSchemas() throws { - let sqlite = SQLContext(configuration: .test) + let sqlite = try SQLContextTest() var statements: [String] = [] sqlite.trace { sql in self.logger.info("SQL: \(sql)") @@ -946,7 +968,7 @@ final class SQLContextTests: XCTestCase { } func testSQLRefType() throws { - let sqlite = SQLContext(configuration: .test) + let sqlite = try SQLContextTest() var statements: [String] = [] sqlite.trace { sql in self.logger.info("SQL: \(sql)") From e0abbdab5a0ea70f9d01cb94a76e5b8cb46e1371 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Wed, 3 Sep 2025 13:29:20 -0400 Subject: [PATCH 2/4] Add compileOptions function and conditional check for upsert support (#18) * Linux support * Add SQLite3 package for Linux * Upate SQLite3 package for Linux * Disable SwiftToolchainCSQLite module * Fix import OSLog for SKIP * Handle condition availability of SQLiteConfiguration.platform * import XCTest * Update test configuration * Remove import of SwiftToolchainCSQLite * compile-options Add SQLContext.compileOptions to get a list of build-time flags --- Sources/SkipSQL/SQLCodable.swift | 8 +++++++ Sources/SkipSQL/SQLContext.swift | 28 +++++++++++++++++++++++ Sources/SkipSQL/SQLite.swift | 7 ++++++ Sources/SkipSQL/SQLiteCLibrary.swift | 4 ++++ Sources/SkipSQL/SQLiteJLibrary.swift | 1 + Sources/SkipSQL/SQLiteLibrary.swift | 1 + Sources/SkipSQLPlus/SQLPlusCLibrary.swift | 4 ++++ Sources/SkipSQLPlus/SQLPlusJLibrary.swift | 1 + Tests/SkipSQLTests/SQLContextTests.swift | 14 ++++++++---- 9 files changed, 63 insertions(+), 5 deletions(-) diff --git a/Sources/SkipSQL/SQLCodable.swift b/Sources/SkipSQL/SQLCodable.swift index 9057c2d..2c0b1b8 100644 --- a/Sources/SkipSQL/SQLCodable.swift +++ b/Sources/SkipSQL/SQLCodable.swift @@ -198,6 +198,9 @@ public extension SQLContext { let pkColumns = T.primaryKeyColumns if update == false, upsert == true, !pkColumns.isEmpty { + if !supports(feature: .upsert) { + throw SQLNotSupportedError(errorDescription: "Upsert syntax is not supported in this version of SQLite (\(self.version))") + } expression.append(" ON CONFLICT(") expression.append(pkColumns.map({ $0.quotedName() }).joined(separator: ", ")) expression.append(") DO UPDATE SET ") @@ -937,6 +940,11 @@ public extension SQLRepresentable { } } +/// An attempt was made to use a SQLite feature that is not supported, either due to the version or compilation options. +public struct SQLNotSupportedError : LocalizedError { + public var errorDescription: String? +} + public enum SQLBindingError : Error { case unknownColumn(SQLColumn) case nullColumn(SQLColumn) diff --git a/Sources/SkipSQL/SQLContext.swift b/Sources/SkipSQL/SQLContext.swift index e1ccf7c..844394f 100644 --- a/Sources/SkipSQL/SQLContext.swift +++ b/Sources/SkipSQL/SQLContext.swift @@ -24,6 +24,11 @@ public class SQLContext { SQLite3.sqlite3_changes(db) } + /// Returns the version string of the current SQLite instance. + public var version: String { + SQLite3.sqlite3_libversion().flatMap({ String(cString: $0) }) ?? "unknown" + } + /// Returns a number signifying the major, minor, and patch release of the underlying SQLite library. /// /// E.g., 3001002 == 3.1.2 @@ -370,6 +375,29 @@ public class SQLContext { set { _ = try? exec(sql: "PRAGMA user_version = \(newValue)") } } + /// Returns the map of compile options that were used to create the current SQLite build + public lazy var compileOptions: [String: SQLValue] = { + var options = (try? selectAll(sql: "PRAGMA compile_options")) ?? [] + var opts: [String: SQLValue] = [:] + for optionsResult in options { + if let optionString = optionsResult.first?.textValue { + // some options are raw (like "ENABLE_API_ARMOR"), and options options are delimited (like "COMPILER=clang-17.0.0") + let parts = optionString.split(separator: "=", maxSplits: 2) + if parts.count == 1 { + opts[parts[0].description] = SQLValue.null + } else { + let valueString = parts[1].description + if let number = Int64(valueString) { + opts[parts[0].description] = SQLValue.long(number) + } else { + opts[parts[0].description] = SQLValue.text(valueString) + } + } + } + } + return opts + }() + /// Quotes the given string public func quoteSingle(_ string: String) -> String { string.quote(#"'"#) diff --git a/Sources/SkipSQL/SQLite.swift b/Sources/SkipSQL/SQLite.swift index d2312f4..52a477f 100644 --- a/Sources/SkipSQL/SQLite.swift +++ b/Sources/SkipSQL/SQLite.swift @@ -53,6 +53,7 @@ public enum SQLAction : Int32 { public enum SQLiteFeature { case rowValueSyntax // WHERE (a,b) = (1,2) + case upsert // INSERT INTO ... ON CONFLICT(x) DO UPDATE SET ... case renameColumn // ALTER TABLE ... RENAME COLUMN case partialIntegrityCheck // PRAGMA integrity_check(table) case sqliteSchemaTable // sqlite_master => sqlite_schema @@ -66,13 +67,19 @@ public enum SQLiteFeature { /// The minimum SQLite version for the given feature public var minimumSupportedVersion: Int32 { switch self { + // Android 9 (API 28): 3.22 case .rowValueSyntax: return 3_015_000 // https://sqlite.org/rowvalue.html#backwards_compatibility + case .upsert: return 3_024_000 // https://sqlite.org/lang_upsert.html#history case .renameColumn: return 3_025_000 + // iOS 13 / Android 10 (API 30): 3.28 + // Android 13 (API 33): 3.32 case .partialIntegrityCheck: return 3_033_000 case .sqliteSchemaTable: return 3_033_000 case .selectReturning: return 3_035_000 case .dropColumn: return 3_035_000 + // iOS 15: 3.36 case .jsonFunction: return 3_038_000 // 2022-02-22 + // iOS 16 / Android 14 (API 34): 3.39 case .rightJoin: return 3_039_000 case .fullOuterJoin: return 3_039_000 case .rowValueInSyntax: return 3_039_000 // https://sqlite.org/rowvalue.html#rvinop, supposedly 3.24, but observed to fail in 3.28 diff --git a/Sources/SkipSQL/SQLiteCLibrary.swift b/Sources/SkipSQL/SQLiteCLibrary.swift index 4f587fe..a1aac3c 100644 --- a/Sources/SkipSQL/SQLiteCLibrary.swift +++ b/Sources/SkipSQL/SQLiteCLibrary.swift @@ -196,6 +196,10 @@ final class SQLiteCLibrary : SQLiteLibrary { SQLite3.sqlite3_shutdown() } + func sqlite3_libversion() -> sqlite3_cstring_ptr? { + SQLite3.sqlite3_libversion() + } + func sqlite3_libversion_number() -> Int32 { SQLite3.sqlite3_libversion_number() } diff --git a/Sources/SkipSQL/SQLiteJLibrary.swift b/Sources/SkipSQL/SQLiteJLibrary.swift index 8115a01..04253c9 100644 --- a/Sources/SkipSQL/SQLiteJLibrary.swift +++ b/Sources/SkipSQL/SQLiteJLibrary.swift @@ -80,6 +80,7 @@ internal final class SQLiteJNALibrary : SQLiteLibrary { /* SKIP INSERT: external */ func sqlite3_backup_pagecount(_ backup: OpaquePointer) -> Int32 /* SKIP INSERT: external */ func sqlite3_initialize() -> Int32 /* SKIP INSERT: external */ func sqlite3_shutdown() -> Int32 + /* SKIP INSERT: external */ func sqlite3_libversion() -> sqlite3_cstring_ptr? /* SKIP INSERT: external */ func sqlite3_libversion_number() -> Int32 /* SKIP INSERT: external */ func sqlite3_extended_result_codes(_ db: OpaquePointer, _ on: Int32) -> Int32 /* SKIP INSERT: external */ func sqlite3_free(_ ptr: OpaquePointer) diff --git a/Sources/SkipSQL/SQLiteLibrary.swift b/Sources/SkipSQL/SQLiteLibrary.swift index c2d3fab..d1fd09c 100644 --- a/Sources/SkipSQL/SQLiteLibrary.swift +++ b/Sources/SkipSQL/SQLiteLibrary.swift @@ -126,6 +126,7 @@ public protocol SQLiteLibrary : NativeLibrary { // Other Functions func sqlite3_initialize() -> Int32 func sqlite3_shutdown() -> Int32 + func sqlite3_libversion() -> sqlite3_cstring_ptr? func sqlite3_libversion_number() -> Int32 //func sqlite3_config(option: Int32, values: Object...) -> Int32 diff --git a/Sources/SkipSQLPlus/SQLPlusCLibrary.swift b/Sources/SkipSQLPlus/SQLPlusCLibrary.swift index a56eb6d..8dc35b7 100644 --- a/Sources/SkipSQLPlus/SQLPlusCLibrary.swift +++ b/Sources/SkipSQLPlus/SQLPlusCLibrary.swift @@ -197,6 +197,10 @@ internal final class SQLPlusCLibrary : SQLiteLibrary { SQLExt.sqlite3_shutdown() } + func sqlite3_libversion() -> sqlite3_cstring_ptr? { + SQLExt.sqlite3_libversion_number() + } + func sqlite3_libversion_number() -> Int32 { SQLExt.sqlite3_libversion_number() } diff --git a/Sources/SkipSQLPlus/SQLPlusJLibrary.swift b/Sources/SkipSQLPlus/SQLPlusJLibrary.swift index 78d30d9..28fca42 100644 --- a/Sources/SkipSQLPlus/SQLPlusJLibrary.swift +++ b/Sources/SkipSQLPlus/SQLPlusJLibrary.swift @@ -63,6 +63,7 @@ internal final class SQLPlusJNALibrary : SQLiteLibrary { /* SKIP INSERT: external */ func sqlite3_backup_pagecount(_ backup: OpaquePointer) -> Int32 /* SKIP INSERT: external */ func sqlite3_initialize() -> Int32 /* SKIP INSERT: external */ func sqlite3_shutdown() -> Int32 + /* SKIP INSERT: external */ func sqlite3_libversion() -> sqlite3_cstring_ptr? /* SKIP INSERT: external */ func sqlite3_libversion_number() -> Int32 /* SKIP INSERT: external */ func sqlite3_extended_result_codes(_ db: OpaquePointer, _ on: Int32) -> Int32 /* SKIP INSERT: external */ func sqlite3_free(_ ptr: OpaquePointer) diff --git a/Tests/SkipSQLTests/SQLContextTests.swift b/Tests/SkipSQLTests/SQLContextTests.swift index f73645b..cba0675 100644 --- a/Tests/SkipSQLTests/SQLContextTests.swift +++ b/Tests/SkipSQLTests/SQLContextTests.swift @@ -57,8 +57,7 @@ final class SQLContextTests: XCTestCase { sqlite.trace { sql in self.logger.info("SQL: \(sql)") } - logger.info("connected to SQLite version: \(sqlite.versionNumber)") - + logger.info("connected to SQLite version: \(sqlite.version) (\(sqlite.versionNumber)) with compile options: \(sqlite.compileOptions)") XCTAssertEqual(0, sqlite.userVersion) sqlite.userVersion += 1 XCTAssertEqual(1, sqlite.userVersion) @@ -66,7 +65,6 @@ final class SQLContextTests: XCTestCase { func testSQLite() throws { let sqlite = try SQLContextTest() - logger.info("connected to SQLite version: \(sqlite.versionNumber)") _ = try sqlite.selectAll(sql: "SELECT 1") _ = try sqlite.selectAll(sql: "SELECT CURRENT_TIMESTAMP") @@ -564,7 +562,7 @@ final class SQLContextTests: XCTestCase { ob.txt = "NMO" ob.num = nil ob.id = 4 - try sqlite.insert(ob, upsert: false) + try sqlite.insert(ob) ob.txt = "ZZZ" do { @@ -576,7 +574,13 @@ final class SQLContextTests: XCTestCase { } // try again as an upsert - try sqlite.insert(ob, upsert: true) + let supportsUpsert = sqlite.supports(feature: .upsert) + if !supportsUpsert { + // we're on an old version of SQLite (e.g., Android API 28 => sqlite 3.22), so we cannot upsert; + // instead, we manually delete the instance instead + try sqlite.delete(instances: [ob]) + } + try sqlite.insert(ob, upsert: supportsUpsert) let ob4 = ob XCTAssertEqual(SQLValue.long(5), try count(table: "DEMO_TABLE")) From 057e82dd5208f2da23683f6c16620df173fd831c Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Wed, 3 Sep 2025 14:49:38 -0400 Subject: [PATCH 3/4] Fix call to sqlite3_libversion --- Sources/SkipSQLPlus/SQLPlusCLibrary.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SkipSQLPlus/SQLPlusCLibrary.swift b/Sources/SkipSQLPlus/SQLPlusCLibrary.swift index 8dc35b7..dd2c664 100644 --- a/Sources/SkipSQLPlus/SQLPlusCLibrary.swift +++ b/Sources/SkipSQLPlus/SQLPlusCLibrary.swift @@ -198,7 +198,7 @@ internal final class SQLPlusCLibrary : SQLiteLibrary { } func sqlite3_libversion() -> sqlite3_cstring_ptr? { - SQLExt.sqlite3_libversion_number() + SQLExt.sqlite3_libversion() } func sqlite3_libversion_number() -> Int32 { From b70d76f9a87ce594b09f1d76f49cb287019bdd30 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Thu, 4 Sep 2025 13:26:15 -0400 Subject: [PATCH 4/4]