diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift index d79b7423..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, -1, 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 eb3d1975..c6ef826d 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,37 +194,84 @@ 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): + 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): + result = sqlite3_bind_int64(statement, index, int) + case .null: + result = sqlite3_bind_null(statement, index) + case .text(let text): + 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.withLowercasedUTF8Text { + sqlite3_bind_text(statement, index, $0, $1, SQLITE_TRANSIENT) } + case .invalid(let error): + throw error } + guard result == SQLITE_OK + else { throw DatabaseError(resultCode: ResultCode(rawValue: result)) } } } +extension String { + func withUTF8Text(_ body: (UnsafePointer, Int32) -> R) -> R { + var text = self + return text.withUTF8 { utf8 in + guard let base = utf8.baseAddress + else { return withUnsafePointer(to: 0 as CChar) { body($0, 0) } } + return base.withMemoryRebound(to: CChar.self, capacity: utf8.count) { + body($0, Int32(utf8.count)) + } + } + } +} + +extension UUID { + func withLowercasedUTF8Text(_ body: (UnsafePointer, Int32) -> R) -> R { + 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) { + body($0, 36) + } + } + } +} + +private let hexDigits = Array("0123456789abcdef".utf8) + @usableFromInline struct Int64OverflowError: Error { let unsignedInteger: UInt64 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..73154ffd --- /dev/null +++ b/Tests/SQLiteDataTests/StringTests.swift @@ -0,0 +1,57 @@ +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) + } + } + + @Test func `bind and fetch NUL strings`() throws { + 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) + } +} + +@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"