From 7ac0da8002b1738317d016a22e704e7279e91686 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 18 Aug 2026 10:20:30 -0500 Subject: [PATCH 1/4] Fix strings with non-terminating NUL characters. --- .../CustomFunctions.swift | 2 +- .../SQLiteFunctionDecoder.swift | 4 +- .../SQLiteQueryDecoder.swift | 4 +- Tests/SQLiteDataTests/StringTests.swift | 63 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 Tests/SQLiteDataTests/StringTests.swift diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift index d79b7423..63e753ad 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift @@ -255,7 +255,7 @@ extension QueryBinding { case .null: sqlite3_result_null(db) case .text(let text): - sqlite3_result_text(db, text, -1, SQLITE_TRANSIENT) + sqlite3_result_text(db, text, Int32(text.utf8.count), SQLITE_TRANSIENT) case .uint(let uint) where uint <= UInt64(Int64.max): sqlite3_result_int64(db, Int64(uint)) case .uint(let uint): diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift index d0fb06d9..e7a4aa9a 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift @@ -133,7 +133,9 @@ struct SQLiteFunctionDecoder: QueryDecoder { try reportTypeMismatch(String.self) } defer { currentIndex += 1 } - return String(cString: sqlite3_value_text(value)) + let text = sqlite3_value_text(value) + let byteCount = Int(sqlite3_value_bytes(value)) + return String(decoding: UnsafeBufferPointer(start: text, count: byteCount), as: UTF8.self) } @inlinable diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift index edbdd195..aa91cc43 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift @@ -111,7 +111,9 @@ struct SQLiteQueryDecoder: QueryDecoder { try reportTypeMismatch(String.self) } defer { currentIndex += 1 } - return String(cString: sqlite3_column_text(statement, currentIndex)) + let text = sqlite3_column_text(statement, currentIndex) + let byteCount = Int(sqlite3_column_bytes(statement, currentIndex)) + return String(decoding: UnsafeBufferPointer(start: text, count: byteCount), as: UTF8.self) } @inlinable diff --git a/Tests/SQLiteDataTests/StringTests.swift b/Tests/SQLiteDataTests/StringTests.swift new file mode 100644 index 00000000..864731e5 --- /dev/null +++ b/Tests/SQLiteDataTests/StringTests.swift @@ -0,0 +1,63 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) +struct NulStringTests { + @Dependency(\.defaultDatabase) var database + + @Test func `decode string with NUL characters`() throws { + try database.read { db in + let value = try #sql("SELECT 'a' || char(0) || 'b'", as: String.self).fetchOne(db) + #expect(value == stringWithNul) + } + } + + // We currently cannot insert strings with NUL characters until + // https://github.com/groue/GRDB.swift/pull/1880 is merged. + @Test func `bind and fetch NUL strings`() throws { + withKnownIssue("Binding strings with NUL's doesn't work") { + try database.read { db in + let back = try #sql("SELECT \(bind: stringWithNul)", as: String.self).fetchOne(db) + #expect(back == stringWithNul) + } + } + withKnownIssue("Inserting strings with NUL's doesn't work") { + let insertedRecord = try #require( + try database.write { db in + try Record.insert { Record.Draft(value: stringWithNul) } + .returning(\.self) + .fetchOne(db) + } + ) + #expect(insertedRecord.value == stringWithNul) + } + } +} + +@Table +private struct Record: Equatable { + let id: Int + var value: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "value" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + return database + } +} + +private let stringWithNul = "a\u{0}b" From 8a306bb4781b015524251b41f140b6ae2208571f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 18 Aug 2026 11:46:55 -0700 Subject: [PATCH 2/4] Bind directly to the SQLite statement --- .../StructuredQueries+GRDB/QueryCursor.swift | 66 +++++++++++-------- Tests/SQLiteDataTests/StringTests.swift | 28 ++++---- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index eb3d1975..6b895a64 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -1,4 +1,4 @@ -public import Foundation +import Foundation public import GRDB import GRDBSQLite public import StructuredQueriesCore @@ -183,7 +183,9 @@ extension Database { sql = "SELECT 1 WHERE 0 -- Empty query generated by StructuredQueries" } let statement = try makeStatement(sql: sql) - statement.arguments = try StatementArguments(bindings.map { try $0.databaseValue }) + for (index, binding) in zip(Int32(1)..., bindings) { + try binding.bind(to: statement.sqliteStatement, at: index) + } return ( statement, SQLiteQueryDecoder(statement: statement.sqliteStatement) @@ -192,34 +194,40 @@ extension Database { } extension QueryBinding { - @inlinable - var databaseValue: DatabaseValue { - get throws { - switch self { - case .blob(let blob): - return Data(blob).databaseValue - case .bool(let bool): - return (bool ? 1 : 0).databaseValue - case .date(let date): - return date.iso8601String.databaseValue - case .double(let double): - return double.databaseValue - case .int(let int): - return int.databaseValue - case .null: - return .null - case .text(let text): - return text.databaseValue - case .uint(let uint) where uint <= UInt64(Int64.max): - return uint.databaseValue - case .uint(let uint): - throw Int64OverflowError(unsignedInteger: uint) - case .uuid(let uuid): - return uuid.uuidString.lowercased().databaseValue - case .invalid(let error): - throw error - } + @usableFromInline + func bind(to statement: SQLiteStatement, at index: Int32) throws { + let result: Int32 + switch self { + case .blob(let blob): + result = + blob.isEmpty + ? sqlite3_bind_zeroblob(statement, index, 0) + : sqlite3_bind_blob(statement, index, blob, Int32(blob.count), SQLITE_TRANSIENT) + case .bool(let bool): + result = sqlite3_bind_int64(statement, index, bool ? 1 : 0) + case .date(let date): + let text = date.iso8601String + result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + case .double(let double): + result = sqlite3_bind_double(statement, index, double) + case .int(let int): + result = sqlite3_bind_int64(statement, index, int) + case .null: + result = sqlite3_bind_null(statement, index) + case .text(let text): + result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + case .uint(let uint) where uint <= UInt64(Int64.max): + result = sqlite3_bind_int64(statement, index, Int64(uint)) + case .uint(let uint): + throw Int64OverflowError(unsignedInteger: uint) + case .uuid(let uuid): + let text = uuid.uuidString.lowercased() + result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + case .invalid(let error): + throw error } + guard result == SQLITE_OK + else { throw DatabaseError(resultCode: ResultCode(rawValue: result)) } } } diff --git a/Tests/SQLiteDataTests/StringTests.swift b/Tests/SQLiteDataTests/StringTests.swift index 864731e5..73154ffd 100644 --- a/Tests/SQLiteDataTests/StringTests.swift +++ b/Tests/SQLiteDataTests/StringTests.swift @@ -14,25 +14,19 @@ struct NulStringTests { } } - // We currently cannot insert strings with NUL characters until - // https://github.com/groue/GRDB.swift/pull/1880 is merged. @Test func `bind and fetch NUL strings`() throws { - withKnownIssue("Binding strings with NUL's doesn't work") { - try database.read { db in - let back = try #sql("SELECT \(bind: stringWithNul)", as: String.self).fetchOne(db) - #expect(back == stringWithNul) - } - } - withKnownIssue("Inserting strings with NUL's doesn't work") { - let insertedRecord = try #require( - try database.write { db in - try Record.insert { Record.Draft(value: stringWithNul) } - .returning(\.self) - .fetchOne(db) - } - ) - #expect(insertedRecord.value == stringWithNul) + try database.read { db in + let back = try #sql("SELECT \(bind: stringWithNul)", as: String.self).fetchOne(db) + #expect(back == stringWithNul) } + let insertedRecord = try #require( + try database.write { db in + try Record.insert { Record.Draft(value: stringWithNul) } + .returning(\.self) + .fetchOne(db) + } + ) + #expect(insertedRecord.value == stringWithNul) } } From 209af88916bf88bdfe314efa5de7989103671242 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 18 Aug 2026 11:58:22 -0700 Subject: [PATCH 3/4] perf --- .../StructuredQueries+GRDB/QueryCursor.swift | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index 6b895a64..6d9cd64a 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -206,8 +206,7 @@ extension QueryBinding { case .bool(let bool): result = sqlite3_bind_int64(statement, index, bool ? 1 : 0) case .date(let date): - let text = date.iso8601String - result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + result = date.iso8601String.bind(to: statement, at: index) case .double(let double): result = sqlite3_bind_double(statement, index, double) case .int(let int): @@ -215,14 +214,13 @@ extension QueryBinding { case .null: result = sqlite3_bind_null(statement, index) case .text(let text): - result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + result = text.bind(to: statement, at: index) case .uint(let uint) where uint <= UInt64(Int64.max): result = sqlite3_bind_int64(statement, index, Int64(uint)) case .uint(let uint): throw Int64OverflowError(unsignedInteger: uint) case .uuid(let uuid): - let text = uuid.uuidString.lowercased() - result = sqlite3_bind_text(statement, index, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + result = uuid.bind(to: statement, at: index) case .invalid(let error): throw error } @@ -231,6 +229,43 @@ extension QueryBinding { } } +extension String { + fileprivate func bind(to statement: SQLiteStatement, at index: Int32) -> Int32 { + var text = self + return text.withUTF8 { utf8 in + guard let base = utf8.baseAddress + else { return sqlite3_bind_text(statement, index, "", 0, SQLITE_TRANSIENT) } + return base.withMemoryRebound(to: CChar.self, capacity: utf8.count) { + sqlite3_bind_text(statement, index, $0, Int32(utf8.count), SQLITE_TRANSIENT) + } + } + } +} + +extension UUID { + fileprivate func bind(to statement: SQLiteStatement, at index: Int32) -> Int32 { + withUnsafeTemporaryAllocation(of: UInt8.self, capacity: 36) { utf8 in + withUnsafeBytes(of: uuid) { bytes in + var offset = 0 + for (byteIndex, byte) in bytes.enumerated() { + if byteIndex == 4 || byteIndex == 6 || byteIndex == 8 || byteIndex == 10 { + utf8[offset] = UInt8(ascii: "-") + offset += 1 + } + utf8[offset] = hexDigits[Int(byte >> 4)] + utf8[offset + 1] = hexDigits[Int(byte & 0xF)] + offset += 2 + } + } + return utf8.baseAddress!.withMemoryRebound(to: CChar.self, capacity: 36) { + sqlite3_bind_text(statement, index, $0, 36, SQLITE_TRANSIENT) + } + } + } +} + +private let hexDigits = Array("0123456789abcdef".utf8) + @usableFromInline struct Int64OverflowError: Error { let unsignedInteger: UInt64 From 3f96ad8ffb7efb8b825e91017093567cc0285c1f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 18 Aug 2026 12:04:16 -0700 Subject: [PATCH 4/4] parity --- .../CustomFunctions.swift | 18 +++++++++++---- .../StructuredQueries+GRDB/QueryCursor.swift | 22 ++++++++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift index 63e753ad..b8e5c160 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift @@ -243,25 +243,35 @@ extension QueryBinding { fileprivate func result(db: OpaquePointer?) { switch self { case .blob(let blob): - sqlite3_result_blob(db, Array(blob), Int32(blob.count), SQLITE_TRANSIENT) + if blob.isEmpty { + sqlite3_result_zeroblob(db, 0) + } else { + sqlite3_result_blob(db, blob, Int32(blob.count), SQLITE_TRANSIENT) + } case .bool(let bool): sqlite3_result_int64(db, bool ? 1 : 0) case .double(let double): sqlite3_result_double(db, double) case .date(let date): - sqlite3_result_text(db, date.iso8601String, -1, SQLITE_TRANSIENT) + date.iso8601String.withUTF8Text { + sqlite3_result_text(db, $0, $1, SQLITE_TRANSIENT) + } case .int(let int): sqlite3_result_int64(db, int) case .null: sqlite3_result_null(db) case .text(let text): - sqlite3_result_text(db, text, Int32(text.utf8.count), SQLITE_TRANSIENT) + text.withUTF8Text { + sqlite3_result_text(db, $0, $1, SQLITE_TRANSIENT) + } case .uint(let uint) where uint <= UInt64(Int64.max): sqlite3_result_int64(db, Int64(uint)) case .uint(let uint): sqlite3_result_error(db, "Unsigned integer \(uint) overflows Int64.max", -1) case .uuid(let uuid): - sqlite3_result_text(db, uuid.uuidString.lowercased(), -1, SQLITE_TRANSIENT) + uuid.withLowercasedUTF8Text { + sqlite3_result_text(db, $0, $1, SQLITE_TRANSIENT) + } case .invalid(let error): sqlite3_result_error(db, error.underlyingError.localizedDescription, -1) } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index 6d9cd64a..c6ef826d 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -206,7 +206,9 @@ extension QueryBinding { case .bool(let bool): result = sqlite3_bind_int64(statement, index, bool ? 1 : 0) case .date(let date): - result = date.iso8601String.bind(to: statement, at: index) + result = date.iso8601String.withUTF8Text { + sqlite3_bind_text(statement, index, $0, $1, SQLITE_TRANSIENT) + } case .double(let double): result = sqlite3_bind_double(statement, index, double) case .int(let int): @@ -214,13 +216,17 @@ extension QueryBinding { case .null: result = sqlite3_bind_null(statement, index) case .text(let text): - result = text.bind(to: statement, at: index) + result = text.withUTF8Text { + sqlite3_bind_text(statement, index, $0, $1, SQLITE_TRANSIENT) + } case .uint(let uint) where uint <= UInt64(Int64.max): result = sqlite3_bind_int64(statement, index, Int64(uint)) case .uint(let uint): throw Int64OverflowError(unsignedInteger: uint) case .uuid(let uuid): - result = uuid.bind(to: statement, at: index) + result = uuid.withLowercasedUTF8Text { + sqlite3_bind_text(statement, index, $0, $1, SQLITE_TRANSIENT) + } case .invalid(let error): throw error } @@ -230,20 +236,20 @@ extension QueryBinding { } extension String { - fileprivate func bind(to statement: SQLiteStatement, at index: Int32) -> Int32 { + func withUTF8Text(_ body: (UnsafePointer, Int32) -> R) -> R { var text = self return text.withUTF8 { utf8 in guard let base = utf8.baseAddress - else { return sqlite3_bind_text(statement, index, "", 0, SQLITE_TRANSIENT) } + else { return withUnsafePointer(to: 0 as CChar) { body($0, 0) } } return base.withMemoryRebound(to: CChar.self, capacity: utf8.count) { - sqlite3_bind_text(statement, index, $0, Int32(utf8.count), SQLITE_TRANSIENT) + body($0, Int32(utf8.count)) } } } } extension UUID { - fileprivate func bind(to statement: SQLiteStatement, at index: Int32) -> Int32 { + func withLowercasedUTF8Text(_ body: (UnsafePointer, Int32) -> R) -> R { withUnsafeTemporaryAllocation(of: UInt8.self, capacity: 36) { utf8 in withUnsafeBytes(of: uuid) { bytes in var offset = 0 @@ -258,7 +264,7 @@ extension UUID { } } return utf8.baseAddress!.withMemoryRebound(to: CChar.self, capacity: 36) { - sqlite3_bind_text(statement, index, $0, 36, SQLITE_TRANSIENT) + body($0, 36) } } }