From a4beff7c75dfea2a82c59ae94ffc801d47770efd Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 18 Aug 2026 12:15:02 -0700 Subject: [PATCH] Improved UUID decoding --- .../StructuredQueries+GRDB/QueryCursor.swift | 23 -------- .../SQLiteFunctionDecoder.swift | 18 +++++- .../SQLiteQueryDecoder.swift | 18 +++++- .../UUID+UUIDString.swift | 56 +++++++++++++++++++ Tests/SQLiteDataTests/UUIDTests.swift | 31 ++++++++++ 5 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 Sources/SQLiteData/StructuredQueries+GRDB/UUID+UUIDString.swift create mode 100644 Tests/SQLiteDataTests/UUIDTests.swift diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index c6ef826d..971bfddc 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -248,29 +248,6 @@ extension String { } } -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 { diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift index e7a4aa9a..14ebf33a 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteFunctionDecoder.swift @@ -147,8 +147,22 @@ struct SQLiteFunctionDecoder: QueryDecoder { @usableFromInline mutating func decode(_ columnType: UUID.Type) throws(QueryDecodingError) -> UUID? { - guard let uuidString = try decode(String.self) else { return nil } - return UUID(uuidString: uuidString) + precondition(argumentCount > currentIndex) + let value = arguments?[Int(currentIndex)] + switch sqlite3_value_type(value) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_TEXT: + break + default: + try reportTypeMismatch(UUID.self) + } + defer { currentIndex += 1 } + let text = sqlite3_value_text(value) + let byteCount = Int(sqlite3_value_bytes(value)) + let utf8 = UnsafeBufferPointer(start: text, count: byteCount) + return UUID(uuidUTF8: utf8) ?? UUID(uuidString: String(decoding: utf8, as: UTF8.self)) } @usableFromInline diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift index aa91cc43..d4c962d7 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift @@ -125,8 +125,22 @@ struct SQLiteQueryDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: UUID.Type) throws(QueryDecodingError) -> UUID? { - guard let uuidString = try decode(String.self) else { return nil } - guard let uuid = UUID(uuidString: uuidString) else { throw .other(InvalidUUID()) } + switch sqlite3_column_type(statement, currentIndex) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_TEXT: + break + default: + try reportTypeMismatch(UUID.self) + } + defer { currentIndex += 1 } + let text = sqlite3_column_text(statement, currentIndex) + let byteCount = Int(sqlite3_column_bytes(statement, currentIndex)) + let utf8 = UnsafeBufferPointer(start: text, count: byteCount) + if let uuid = UUID(uuidUTF8: utf8) { return uuid } + guard let uuid = UUID(uuidString: String(decoding: utf8, as: UTF8.self)) + else { throw .other(InvalidUUID()) } return uuid } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/UUID+UUIDString.swift b/Sources/SQLiteData/StructuredQueries+GRDB/UUID+UUIDString.swift new file mode 100644 index 00000000..c9e35ede --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/UUID+UUIDString.swift @@ -0,0 +1,56 @@ +public import Foundation + +extension UUID { + @usableFromInline + init?(uuidUTF8 utf8: UnsafeBufferPointer) { + guard utf8.count == 36 else { return nil } + var raw: uuid_t = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + let parsed = withUnsafeMutableBytes(of: &raw) { bytes in + var index = 0 + for byteIndex in 0..<16 { + if byteIndex == 4 || byteIndex == 6 || byteIndex == 8 || byteIndex == 10 { + guard utf8[index] == UInt8(ascii: "-") else { return false } + index += 1 + } + guard let high = hexValue(utf8[index]), let low = hexValue(utf8[index + 1]) + else { return false } + bytes[byteIndex] = high << 4 | low + index += 2 + } + return true + } + guard parsed else { return nil } + self.init(uuid: raw) + } + + 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 func hexValue(_ byte: UInt8) -> UInt8? { + switch byte { + case UInt8(ascii: "0")...UInt8(ascii: "9"): byte - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): byte - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): byte - UInt8(ascii: "A") + 10 + default: nil + } +} + +private let hexDigits = Array("0123456789abcdef".utf8) diff --git a/Tests/SQLiteDataTests/UUIDTests.swift b/Tests/SQLiteDataTests/UUIDTests.swift new file mode 100644 index 00000000..6df150ef --- /dev/null +++ b/Tests/SQLiteDataTests/UUIDTests.swift @@ -0,0 +1,31 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try DatabaseQueue())) +struct UUIDTests { + @Dependency(\.defaultDatabase) var database + + @Test func `decode matches Foundation parsing`() throws { + try database.read { db in + for text in [ + "deadbeef-dead-beef-dead-beefdeadbeef", + "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF", + "A1b2C3d4-E5f6-7890-aB12-Cd34eF567890", + "00000000-0000-0000-0000-000000000000", + ] { + let decoded = try #sql("SELECT \(bind: text)", as: UUID.self).fetchOne(db) + #expect(decoded == UUID(uuidString: text), "\(text)") + } + } + } + + @Test func roundtrip() throws { + let uuid = UUID() + try database.read { db in + let decoded = try #sql("SELECT \(bind: uuid)", as: UUID.self).fetchOne(db) + #expect(decoded == uuid) + } + } +}