diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f0d8357..6be19ff9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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 ### Added diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 453a005db..a3874a070 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -287,11 +287,9 @@ private actor DuckDBConnectionActor { } let colCount = duckdb_column_count(&result) - let rowCount = duckdb_row_count(&result) - var columns: [String] = [] var columnTypeNames: [String] = [] - + var columnTypes: [duckdb_type] = [] for i in 0...Continuation + ) throws { + let castExprs = columns.enumerated().map { i, name in + castExpression(for: columnTypes[i], column: name) + } + let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) + + 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 } - - var castExprs: [String] = [] - for (i, name) in raw.columns.enumerated() { - let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - if tzColIndices.contains(i) { - castExprs.append( - "CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\"" - ) - } 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 } + + let castExprs = raw.columns.enumerated().map { i, name in + castExpression(for: raw.columnTypes[i], column: name) + } + let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) - let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - .hasSuffix(";") ? String(query.dropLast()) : query - let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tz_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 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 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,31 +677,19 @@ 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) } } -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 @@ -879,10 +962,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 +1231,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 +1285,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 +1373,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/TableProPluginKit/HugeIntFormatter.swift b/Plugins/TableProPluginKit/HugeIntFormatter.swift new file mode 100644 index 000000000..a1a5bf0dc --- /dev/null +++ b/Plugins/TableProPluginKit/HugeIntFormatter.swift @@ -0,0 +1,48 @@ +// +// HugeIntFormatter.swift +// TableProPluginKit +// + +import Foundation + +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) + } + let invLower = ~lower + let invUpper = ~upperBits + let (sumLower, carry) = invLower.addingReportingOverflow(1) + let sumUpper = invUpper &+ (carry ? 1 : 0) + return "-\(formatUnsigned(upper: sumUpper, lower: sumLower))" + } + + public 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..