From 302a1d19117ccf73765744c0491901d04544f7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 19 May 2026 12:08:58 +0700 Subject: [PATCH 1/3] fix(plugin-duckdb): render GEOMETRY as WKT and 9 other fixes (#1324) --- CHANGELOG.md | 10 + Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 212 +++++++++++++----- .../DuckDBDriverPlugin/HugeIntFormatter.swift | 48 ++++ 3 files changed, 208 insertions(+), 62 deletions(-) create mode 100644 Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f0d8357..b9841c2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- DuckDB Spatial `GEOMETRY` columns now render as WKT instead of NULL in the data grid and streaming results (#1324) +- DuckDB `HUGEINT` and `UHUGEINT` formatting no longer crashes on negative values and preserves full 128-bit precision +- DuckDB streaming query results respect the row cap, release resources on cancellation, and pick up `TIMESTAMPTZ`/`TIMETZ` values that previously rendered as NULL +- DuckDB `information_schema` lookups escape schema names so connections with apostrophes in the schema no longer fail +- DuckDB ENUM values are now read from the correct schema for ENUMs outside `main` +- DuckDB `DATE` and `TIMESTAMP` values with BC years render with the canonical ISO leading minus +- DuckDB schema reads no longer race when concurrent connections switch schemas + ## [0.43.0] - 2026-05-18 ### Added diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 453a005db..608644323 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -272,45 +272,132 @@ private actor DuckDBConnectionActor { throw DuckDBPluginError.notConnected } - var result = duckdb_result() - let state = duckdb_query(conn, query, &result) + var probeResult = duckdb_result() + let probeState = duckdb_query(conn, query, &probeResult) - if state == DuckDBError { + if probeState == DuckDBError { let errorMsg: String - if let errPtr = duckdb_result_error(&result) { + if let errPtr = duckdb_result_error(&probeResult) { errorMsg = String(cString: errPtr) } else { errorMsg = "Unknown DuckDB error" } - duckdb_destroy_result(&result) + duckdb_destroy_result(&probeResult) throw DuckDBPluginError.queryFailed(errorMsg) } - let colCount = duckdb_column_count(&result) - let rowCount = duckdb_row_count(&result) + let probeColCount = duckdb_column_count(&probeResult) + var probeColumns: [String] = [] + var probeColumnTypeNames: [String] = [] + for i in 0.. = ["TIMESTAMPTZ", "TIMETZ", "GEOMETRY"] + let needsWrap = probeColumnTypeNames.contains { unrenderable.contains($0) } - for i in 0...Continuation + ) throws { + let unrenderable: Set = ["TIMESTAMPTZ", "TIMETZ", "GEOMETRY"] + var castExprs: [String] = [] + for (i, name) in columns.enumerated() { + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + let typeName = columnTypeNames[i] + if typeName == "GEOMETRY" { + castExprs.append( + "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE ST_AsText(\"\(escaped)\") END AS \"\(escaped)\"" + ) + } else if unrenderable.contains(typeName) { + castExprs.append( + "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\"" + ) } else { - columns.append("column_\(i)") + castExprs.append("\"\(escaped)\"") } - let colType = duckdb_column_type(&result, i) - columnTypeNames.append(Self.typeName(for: colType)) } + var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.hasSuffix(";") { + trimmedQuery = String(trimmedQuery.dropLast()) + } + let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tp_cast" + + var result = duckdb_result() + let state = duckdb_query(connection, wrappedQuery, &result) + if state == DuckDBError { + let errorMsg: String + if let errPtr = duckdb_result_error(&result) { + errorMsg = String(cString: errPtr) + } else { + errorMsg = "Unknown DuckDB error" + } + duckdb_destroy_result(&result) + throw DuckDBPluginError.queryFailed(errorMsg) + } + defer { duckdb_destroy_result(&result) } + + try Self.streamResultRows( + &result, + columns: columns, + columnTypeNames: columnTypeNames, + continuation: continuation + ) + } + + private static func streamResultRows( + _ result: inout duckdb_result, + columns: [String], + columnTypeNames: [String], + continuation: AsyncThrowingStream.Continuation + ) throws { + let colCount = duckdb_column_count(&result) + let rowCount = duckdb_row_count(&result) + continuation.yield(.header(PluginStreamHeader( columns: columns, columnTypeNames: columnTypeNames, estimatedRowCount: Int(rowCount) ))) - for row in 0.. UInt64(PluginRowLimits.emergencyMax) { + Self.logger.warning("streamQuery truncating result from \(rowCount) to \(maxRows) rows") + } + + for row in 0.. = ["TIMESTAMPTZ", "TIMETZ"] - let tzColIndices = raw.columnTypeNames.enumerated().compactMap { idx, name in - tzTypes.contains(name) ? idx : nil - } - guard !tzColIndices.isEmpty, !raw.rows.isEmpty else { return } + let geometryTypes: Set = ["GEOMETRY"] + var patchedColIndices: [Int] = [] var castExprs: [String] = [] for (i, name) in raw.columns.enumerated() { let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - if tzColIndices.contains(i) { + let typeName = raw.columnTypeNames[i] + if tzTypes.contains(typeName) { castExprs.append( "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\"" ) + patchedColIndices.append(i) + } else if geometryTypes.contains(typeName) { + castExprs.append( + "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE ST_AsText(\"\(escaped)\") END AS \"\(escaped)\"" + ) + patchedColIndices.append(i) } else { castExprs.append("\"\(escaped)\"") } } - let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - .hasSuffix(";") ? String(query.dropLast()) : query - let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tz_cast" + guard !patchedColIndices.isEmpty, !raw.rows.isEmpty else { return } + + var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.hasSuffix(";") { + trimmedQuery = String(trimmedQuery.dropLast()) + } + let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tp_cast" var patchResult = duckdb_result() guard duckdb_query(connection, wrappedQuery, &patchResult) == DuckDBSuccess else { return } defer { duckdb_destroy_result(&patchResult) } let patchRowCount = min(duckdb_row_count(&patchResult), UInt64(raw.rows.count)) for row in 0.. String { + static func formatTimestamp(_ ts: duckdb_timestamp) -> String { let parts = duckdb_from_timestamp(ts) let d = parts.date let t = parts.time let micros = t.micros % 1_000_000 + let yearPart = formatYearISO(d.year) if micros == 0 { return String( - format: "%04d-%02d-%02d %02d:%02d:%02d", - d.year, d.month, d.day, t.hour, t.min, t.sec + format: "\(yearPart)-%02d-%02d %02d:%02d:%02d", + d.month, d.day, t.hour, t.min, t.sec ) } return String( - format: "%04d-%02d-%02d %02d:%02d:%02d.%06d", - d.year, d.month, d.day, t.hour, t.min, t.sec, micros + format: "\(yearPart)-%02d-%02d %02d:%02d:%02d.%06d", + d.month, d.day, t.hour, t.min, t.sec, micros ) } + static func formatYearISO(_ year: Int32) -> String { + if year < 0 { + return String(format: "-%04d", -Int(year)) + } + return String(format: "%04d", year) + } + private static func formatTime(_ t: duckdb_time_struct) -> String { let micros = t.micros % 1_000_000 if micros == 0 { @@ -582,25 +682,12 @@ private actor DuckDBConnectionActor { return String(format: "%02d:%02d:%02d.%06d", t.hour, t.min, t.sec, micros) } - private static func formatHugeInt(upper: Int64, lower: UInt64) -> String { - if upper == 0 { - return String(lower) - } - if upper == -1, lower > Int64.max.magnitude { - let val = ~upper - let low = ~lower &+ 1 - return "-\(formatUHugeInt(upper: UInt64(val), lower: low))" - } - return formatUHugeInt(upper: UInt64(upper), lower: lower) + static func formatHugeInt(upper: Int64, lower: UInt64) -> String { + HugeIntFormatter.format(upper: upper, lower: lower) } - private static func formatUHugeInt(upper: UInt64, lower: UInt64) -> String { - if upper == 0 { - return String(lower) - } - let upperDecimal = Decimal(upper) * Decimal(sign: .plus, exponent: 0, significand: Decimal(UInt64.max) + 1) - let result = upperDecimal + Decimal(lower) - return "\(result)" + static func formatUHugeInt(upper: UInt64, lower: UInt64) -> String { + HugeIntFormatter.formatUnsigned(upper: upper, lower: lower) } } @@ -879,10 +966,11 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let typeNames = typeResult.rows.compactMap { $0[safe: 0]?.asText } guard !typeNames.isEmpty else { return [:] } + let quotedSchema = quoteIdentifier(schema) var map: [String: [String]] = [:] for typeName in typeNames { - let quoted = "\"\(typeName.replacingOccurrences(of: "\"", with: "\"\""))\"" - let valuesQuery = "SELECT UNNEST(enum_range(NULL::\(quoted)))::VARCHAR AS value" + let quoted = quoteIdentifier(typeName) + let valuesQuery = "SELECT UNNEST(enum_range(NULL::\(quotedSchema).\(quoted)))::VARCHAR AS value" let valuesResult: PluginQueryResult do { valuesResult = try await execute(query: valuesQuery) @@ -1147,7 +1235,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { - let s = schema ?? currentSchema ?? "main" + let s = (schema ?? currentSchema ?? "main").replacingOccurrences(of: "'", with: "''") return """ SELECT table_schema as schema_name, @@ -1201,7 +1289,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { guard !definition.columns.isEmpty else { return nil } - let schema = _currentSchema + let schema = resolveSchema(nil) let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" let pkColumns = definition.columns.filter { $0.isPrimaryKey } let inlinePK = pkColumns.count == 1 @@ -1289,7 +1377,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func qualifiedTableName(_ table: String) -> String { - "\(quoteIdentifier(_currentSchema)).\(quoteIdentifier(table))" + "\(quoteIdentifier(resolveSchema(nil))).\(quoteIdentifier(table))" } // MARK: - ALTER TABLE DDL diff --git a/Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift b/Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift new file mode 100644 index 000000000..3407c5aa2 --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift @@ -0,0 +1,48 @@ +// +// HugeIntFormatter.swift +// DuckDBDriverPlugin +// + +import Foundation + +internal enum HugeIntFormatter { + static func format(upper: Int64, lower: UInt64) -> String { + let upperBits = UInt64(bitPattern: upper) + if upper >= 0 { + return formatUnsigned(upper: upperBits, lower: lower) + } + let invLower = ~lower + let invUpper = ~upperBits + let (sumLower, carry) = invLower.addingReportingOverflow(1) + let sumUpper = invUpper &+ (carry ? 1 : 0) + return "-\(formatUnsigned(upper: sumUpper, lower: sumLower))" + } + + static func formatUnsigned(upper: UInt64, lower: UInt64) -> String { + if upper == 0 { + return String(lower) + } + var limbs: [UInt32] = [ + UInt32(upper >> 32), + UInt32(upper & 0xFFFF_FFFF), + UInt32(lower >> 32), + UInt32(lower & 0xFFFF_FFFF) + ] + let divisor: UInt64 = 1_000_000_000 + var chunks: [UInt32] = [] + while limbs.contains(where: { $0 != 0 }) { + var rem: UInt64 = 0 + for i in 0.. Date: Tue, 19 May 2026 12:21:41 +0700 Subject: [PATCH 2/3] refactor(plugin-duckdb): enum-based type dispatch and shared HugeIntFormatter --- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 142 +++++++++--------- .../HugeIntFormatter.swift | 8 +- .../Plugins/HugeIntFormatterTests.swift | 89 +++++++++++ 3 files changed, 162 insertions(+), 77 deletions(-) rename Plugins/{DuckDBDriverPlugin => TableProPluginKit}/HugeIntFormatter.swift (86%) create mode 100644 TableProTests/Plugins/HugeIntFormatterTests.swift diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 608644323..a3874a070 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -272,53 +272,53 @@ private actor DuckDBConnectionActor { throw DuckDBPluginError.notConnected } - var probeResult = duckdb_result() - let probeState = duckdb_query(conn, query, &probeResult) + var result = duckdb_result() + let state = duckdb_query(conn, query, &result) - if probeState == DuckDBError { + if state == DuckDBError { let errorMsg: String - if let errPtr = duckdb_result_error(&probeResult) { + if let errPtr = duckdb_result_error(&result) { errorMsg = String(cString: errPtr) } else { errorMsg = "Unknown DuckDB error" } - duckdb_destroy_result(&probeResult) + duckdb_destroy_result(&result) throw DuckDBPluginError.queryFailed(errorMsg) } - let probeColCount = duckdb_column_count(&probeResult) - var probeColumns: [String] = [] - var probeColumnTypeNames: [String] = [] - for i in 0.. = ["TIMESTAMPTZ", "TIMETZ", "GEOMETRY"] - let needsWrap = probeColumnTypeNames.contains { unrenderable.contains($0) } - - if needsWrap { - duckdb_destroy_result(&probeResult) + if columnTypes.contains(where: Self.isUnrenderable) { + duckdb_destroy_result(&result) try Self.streamWrappedQuery( query: query, - columns: probeColumns, - columnTypeNames: probeColumnTypeNames, + columns: columns, + columnTypeNames: columnTypeNames, + columnTypes: columnTypes, connection: conn, continuation: continuation ) return } - var result = probeResult defer { duckdb_destroy_result(&result) } try Self.streamResultRows( &result, - columns: probeColumns, - columnTypeNames: probeColumnTypeNames, + columns: columns, + columnTypeNames: columnTypeNames, continuation: continuation ) } @@ -327,32 +327,14 @@ private actor DuckDBConnectionActor { query: String, columns: [String], columnTypeNames: [String], + columnTypes: [duckdb_type], connection: duckdb_connection, continuation: AsyncThrowingStream.Continuation ) throws { - let unrenderable: Set = ["TIMESTAMPTZ", "TIMETZ", "GEOMETRY"] - var castExprs: [String] = [] - for (i, name) in columns.enumerated() { - let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - let typeName = columnTypeNames[i] - if typeName == "GEOMETRY" { - castExprs.append( - "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE ST_AsText(\"\(escaped)\") END AS \"\(escaped)\"" - ) - } else if unrenderable.contains(typeName) { - castExprs.append( - "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\"" - ) - } else { - castExprs.append("\"\(escaped)\"") - } + let castExprs = columns.enumerated().map { i, name in + castExpression(for: columnTypes[i], column: name) } - - var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedQuery.hasSuffix(";") { - trimmedQuery = String(trimmedQuery.dropLast()) - } - let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tp_cast" + let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) var result = duckdb_result() let state = duckdb_query(connection, wrappedQuery, &result) @@ -496,6 +478,7 @@ private actor DuckDBConnectionActor { return DuckDBRawResult( columns: columns, columnTypeNames: columnTypeNames, + columnTypes: columnTypes, rows: rows, rowsAffected: Int(rowsChanged), executionTime: executionTime, @@ -598,40 +581,19 @@ private actor DuckDBConnectionActor { } } - /// DuckDB v1.5.0 C API: duckdb_value_varchar returns nil for TIMESTAMPTZ and TIMETZ, static func patchTzColumns( _ raw: inout DuckDBRawResult, query: String, connection: duckdb_connection ) { - let tzTypes: Set = ["TIMESTAMPTZ", "TIMETZ"] - let geometryTypes: Set = ["GEOMETRY"] - - var patchedColIndices: [Int] = [] - var castExprs: [String] = [] - for (i, name) in raw.columns.enumerated() { - let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - let typeName = raw.columnTypeNames[i] - if tzTypes.contains(typeName) { - castExprs.append( - "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\"" - ) - patchedColIndices.append(i) - } else if geometryTypes.contains(typeName) { - castExprs.append( - "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE ST_AsText(\"\(escaped)\") END AS \"\(escaped)\"" - ) - patchedColIndices.append(i) - } else { - castExprs.append("\"\(escaped)\"") - } + let patchedColIndices = raw.columnTypes.enumerated().compactMap { idx, type in + isUnrenderable(type) ? idx : nil } - guard !patchedColIndices.isEmpty, !raw.rows.isEmpty else { return } - var trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedQuery.hasSuffix(";") { - trimmedQuery = String(trimmedQuery.dropLast()) + let castExprs = raw.columns.enumerated().map { i, name in + castExpression(for: raw.columnTypes[i], column: name) } - let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tp_cast" + let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) + var patchResult = duckdb_result() guard duckdb_query(connection, wrappedQuery, &patchResult) == DuckDBSuccess else { return } defer { duckdb_destroy_result(&patchResult) } @@ -649,6 +611,39 @@ private actor DuckDBConnectionActor { } } + static func isUnrenderable(_ type: duckdb_type) -> Bool { + switch type { + case DUCKDB_TYPE_TIMESTAMP_TZ, DUCKDB_TYPE_TIME_TZ, DUCKDB_TYPE_GEOMETRY: + return true + default: + return false + } + } + + static func castExpression(for type: duckdb_type, column: String) -> String { + let quoted = quoteIdentifier(column) + switch type { + case DUCKDB_TYPE_GEOMETRY: + return "CASE WHEN \(quoted) IS NULL THEN NULL ELSE ST_AsText(\(quoted)) END AS \(quoted)" + case DUCKDB_TYPE_TIMESTAMP_TZ, DUCKDB_TYPE_TIME_TZ: + return "CASE WHEN \(quoted) IS NULL THEN NULL ELSE CAST(\(quoted) AS VARCHAR) END AS \(quoted)" + default: + return quoted + } + } + + static func buildWrappedQuery(originalQuery: String, castExprs: [String]) -> String { + var trimmed = originalQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix(";") { + trimmed = String(trimmed.dropLast()) + } + return "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmed)) AS _tp_cast" + } + + static func quoteIdentifier(_ ident: String) -> String { + "\"\(ident.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + static func formatTimestamp(_ ts: duckdb_timestamp) -> String { let parts = duckdb_from_timestamp(ts) let d = parts.date @@ -691,9 +686,10 @@ private actor DuckDBConnectionActor { } } -private struct DuckDBRawResult: Sendable { +private struct DuckDBRawResult: @unchecked Sendable { let columns: [String] let columnTypeNames: [String] + let columnTypes: [duckdb_type] var rows: [[PluginCellValue]] let rowsAffected: Int let executionTime: TimeInterval diff --git a/Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift b/Plugins/TableProPluginKit/HugeIntFormatter.swift similarity index 86% rename from Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift rename to Plugins/TableProPluginKit/HugeIntFormatter.swift index 3407c5aa2..a1a5bf0dc 100644 --- a/Plugins/DuckDBDriverPlugin/HugeIntFormatter.swift +++ b/Plugins/TableProPluginKit/HugeIntFormatter.swift @@ -1,12 +1,12 @@ // // HugeIntFormatter.swift -// DuckDBDriverPlugin +// TableProPluginKit // import Foundation -internal enum HugeIntFormatter { - static func format(upper: Int64, lower: UInt64) -> String { +public enum HugeIntFormatter { + public static func format(upper: Int64, lower: UInt64) -> String { let upperBits = UInt64(bitPattern: upper) if upper >= 0 { return formatUnsigned(upper: upperBits, lower: lower) @@ -18,7 +18,7 @@ internal enum HugeIntFormatter { return "-\(formatUnsigned(upper: sumUpper, lower: sumLower))" } - static func formatUnsigned(upper: UInt64, lower: UInt64) -> String { + public static func formatUnsigned(upper: UInt64, lower: UInt64) -> String { if upper == 0 { return String(lower) } diff --git a/TableProTests/Plugins/HugeIntFormatterTests.swift b/TableProTests/Plugins/HugeIntFormatterTests.swift new file mode 100644 index 000000000..435ce5f0f --- /dev/null +++ b/TableProTests/Plugins/HugeIntFormatterTests.swift @@ -0,0 +1,89 @@ +// +// HugeIntFormatterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("HugeIntFormatter") +struct HugeIntFormatterTests { + @Test("Zero") + func zero() { + #expect(HugeIntFormatter.format(upper: 0, lower: 0) == "0") + #expect(HugeIntFormatter.formatUnsigned(upper: 0, lower: 0) == "0") + } + + @Test("Small positive fits in lower limb") + func smallPositive() { + #expect(HugeIntFormatter.format(upper: 0, lower: 42) == "42") + #expect(HugeIntFormatter.format(upper: 0, lower: UInt64.max) == "18446744073709551615") + } + + @Test("Negative one") + func negativeOne() { + #expect(HugeIntFormatter.format(upper: -1, lower: UInt64.max) == "-1") + } + + @Test("Small negative") + func smallNegative() { + #expect(HugeIntFormatter.format(upper: -1, lower: UInt64.max - 41) == "-42") + } + + @Test("Int128 max") + func int128Max() { + #expect(HugeIntFormatter.format(upper: Int64.max, lower: UInt64.max) + == "170141183460469231731687303715884105727") + } + + @Test("Int128 min") + func int128Min() { + #expect(HugeIntFormatter.format(upper: Int64.min, lower: 0) + == "-170141183460469231731687303715884105728") + } + + @Test("Just above Int64 range") + func justAboveInt64Max() { + #expect(HugeIntFormatter.format(upper: 0, lower: UInt64(Int64.max) + 1) + == "9223372036854775808") + } + + @Test("Just below Int64 range") + func justBelowInt64Min() { + #expect(HugeIntFormatter.format(upper: -1, lower: UInt64(bitPattern: Int64.min) - 1) + == "-9223372036854775809") + } + + @Test("UInt128 max preserves full precision") + func uint128Max() { + #expect(HugeIntFormatter.formatUnsigned(upper: UInt64.max, lower: UInt64.max) + == "340282366920938463463374607431768211455") + } + + @Test("Value crossing 2^64 boundary") + func crossing2to64() { + #expect(HugeIntFormatter.formatUnsigned(upper: 1, lower: 0) == "18446744073709551616") + #expect(HugeIntFormatter.formatUnsigned(upper: 1, lower: 1) == "18446744073709551617") + } + + @Test("Negative crossing 2^64 boundary") + func negativeCrossing2to64() { + #expect(HugeIntFormatter.format(upper: -1, lower: 0) == "-18446744073709551616") + #expect(HugeIntFormatter.format(upper: -2, lower: 0) == "-36893488147419103232") + } + + @Test("Result has no leading zeros on most significant chunk") + func noLeadingZeros() { + let result = HugeIntFormatter.formatUnsigned(upper: 0, lower: 1) + #expect(result == "1") + #expect(!result.hasPrefix("0")) + } + + @Test("Chunks past the first are zero-padded to 9 digits") + func internalChunksPadded() { + let result = HugeIntFormatter.formatUnsigned(upper: 1, lower: 0) + #expect(result == "18446744073709551616") + #expect(result.count == 20) + } +} From d63e64e15f8736313a2ebc0ebbd769c4ce18fffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 19 May 2026 12:25:23 +0700 Subject: [PATCH 3/3] docs(changelog): tighten DuckDB Fixed entries --- CHANGELOG.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9841c2f3..6be19ff9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- DuckDB Spatial `GEOMETRY` columns now render as WKT instead of NULL in the data grid and streaming results (#1324) -- DuckDB `HUGEINT` and `UHUGEINT` formatting no longer crashes on negative values and preserves full 128-bit precision -- DuckDB streaming query results respect the row cap, release resources on cancellation, and pick up `TIMESTAMPTZ`/`TIMETZ` values that previously rendered as NULL -- DuckDB `information_schema` lookups escape schema names so connections with apostrophes in the schema no longer fail -- DuckDB ENUM values are now read from the correct schema for ENUMs outside `main` -- DuckDB `DATE` and `TIMESTAMP` values with BC years render with the canonical ISO leading minus -- DuckDB schema reads no longer race when concurrent connections switch schemas +- DuckDB Spatial `GEOMETRY` columns render as WKT, not NULL (#1324) +- DuckDB `HUGEINT` and `UHUGEINT` keep full precision and no longer crash on negatives +- DuckDB streaming results honor the row cap and render `TIMESTAMPTZ`/`TIMETZ`/`GEOMETRY` instead of NULL +- DuckDB schema reads handle apostrophes and concurrent schema switches correctly +- DuckDB ENUMs in non-`main` schemas resolve correctly +- DuckDB `DATE` and `TIMESTAMP` BC years use a leading minus ## [0.43.0] - 2026-05-18