From 7cfa85f3b9d2179e405b60381bef7f78b9376110 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 15:02:36 +0700 Subject: [PATCH 1/2] fix(plugin-duckdb): render VARIANT columns as text --- CHANGELOG.md | 4 ++++ Plugins/DuckDBDriverPlugin/DuckDBConnection.swift | 4 ++-- .../TableProMobileTests/Drivers/DuckDBDriverTests.swift | 8 ++++++++ docs/databases/duckdb.mdx | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9a84c58..b13d96c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Traditional Chinese (繁體中文) language in Settings > General with full UI translation +### Fixed + +- DuckDB VARIANT columns now show their value as text instead of an empty cell. + ## [0.51.1] - 2026-06-16 ### Added diff --git a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift index 666add7c4..645520aea 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift @@ -516,7 +516,7 @@ actor DuckDBConnectionActor { static func isUnrenderable(_ type: duckdb_type) -> Bool { switch type { - case DUCKDB_TYPE_TIMESTAMP_TZ, DUCKDB_TYPE_TIME_TZ, DUCKDB_TYPE_GEOMETRY: + case DUCKDB_TYPE_INVALID, DUCKDB_TYPE_TIMESTAMP_TZ, DUCKDB_TYPE_TIME_TZ, DUCKDB_TYPE_GEOMETRY: return true default: return false @@ -528,7 +528,7 @@ actor DuckDBConnectionActor { 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: + case DUCKDB_TYPE_INVALID, 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 diff --git a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift index b636b4e5a..ba38ca898 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift @@ -115,6 +115,14 @@ final class DuckDBDriverTests: XCTestCase { XCTAssertTrue(value.contains("2024-01-02"), "expected rendered timestamptz, got \(value)") } + func testVariantRendersAsText() async throws { + let driver = try XCTUnwrap(driver) + let result = try await driver.execute(query: "SELECT {'a': 42, 'b': [1, 2, 3]}::VARIANT AS v") + XCTAssertEqual(result.rows.count, 1) + let value = try XCTUnwrap(result.rows[0][0]) + XCTAssertTrue(value.contains("42"), "expected rendered variant text, got \(value)") + } + func testCancelWithoutRunningQueryIsSafe() async throws { let driver = try XCTUnwrap(driver) try await driver.cancelCurrentQuery() diff --git a/docs/databases/duckdb.mdx b/docs/databases/duckdb.mdx index db22938ec..94f355346 100644 --- a/docs/databases/duckdb.mdx +++ b/docs/databases/duckdb.mdx @@ -99,7 +99,7 @@ DuckDB supports a wide range of data types: | Numeric | `INTEGER`, `BIGINT`, `HUGEINT`, `DOUBLE`, `FLOAT`, `DECIMAL` | | String | `VARCHAR`, `TEXT`, `CHAR` | | Date/Time | `DATE`, `TIME`, `TIMESTAMP`, `INTERVAL` | -| Complex | `LIST`, `MAP`, `STRUCT`, `UNION`, `ENUM` | +| Complex | `LIST`, `MAP`, `STRUCT`, `UNION`, `ENUM`, `VARIANT` | | Other | `BOOLEAN`, `BLOB`, `UUID`, `JSON`, `BIT` | ## Limitations From adbda4d2ca2ba05114c3c7e12e26ccd4ce3d45a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 15:12:11 +0700 Subject: [PATCH 2/2] refactor(plugin-duckdb): detect cast-needed columns via render allowlist --- .../DuckDBDriverPlugin/DuckDBConnection.swift | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift index 645520aea..e99c211f7 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift @@ -91,7 +91,7 @@ actor DuckDBConnectionActor { } var raw = Self.extractResult(from: &result, startTime: startTime) - Self.patchTzColumns(&raw, query: query, connection: conn) + Self.patchCastedColumns(&raw, query: query, connection: conn) return raw } @@ -163,7 +163,7 @@ actor DuckDBConnectionActor { } var raw = Self.extractResult(from: &result, startTime: startTime) - Self.patchTzColumns(&raw, query: query, connection: conn) + Self.patchCastedColumns(&raw, query: query, connection: conn) return raw } @@ -204,7 +204,7 @@ actor DuckDBConnectionActor { columnTypeNames.append(Self.typeName(for: colType)) } - if columnTypes.contains(where: Self.isUnrenderable) { + if columnTypes.contains(where: Self.requiresTextCast) { duckdb_destroy_result(&result) try Self.streamWrappedQuery( query: query, @@ -235,7 +235,7 @@ actor DuckDBConnectionActor { continuation: AsyncThrowingStream.Continuation ) throws { let castExprs = columns.enumerated().map { i, name in - castExpression(for: columnTypes[i], column: name) + projection(for: columnTypes[i], column: name) } let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) @@ -484,16 +484,16 @@ actor DuckDBConnectionActor { } } - static func patchTzColumns( + static func patchCastedColumns( _ raw: inout DuckDBRawResult, query: String, connection: duckdb_connection ) { let patchedColIndices = raw.columnTypes.enumerated().compactMap { idx, type in - isUnrenderable(type) ? idx : nil + requiresTextCast(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) + projection(for: raw.columnTypes[i], column: name) } let wrappedQuery = buildWrappedQuery(originalQuery: query, castExprs: castExprs) @@ -514,25 +514,36 @@ actor DuckDBConnectionActor { } } - static func isUnrenderable(_ type: duckdb_type) -> Bool { + static func isNativelyRenderable(_ type: duckdb_type) -> Bool { switch type { - case DUCKDB_TYPE_INVALID, DUCKDB_TYPE_TIMESTAMP_TZ, DUCKDB_TYPE_TIME_TZ, DUCKDB_TYPE_GEOMETRY: + case DUCKDB_TYPE_BOOLEAN, + DUCKDB_TYPE_TINYINT, DUCKDB_TYPE_SMALLINT, DUCKDB_TYPE_INTEGER, DUCKDB_TYPE_BIGINT, DUCKDB_TYPE_HUGEINT, + DUCKDB_TYPE_UTINYINT, DUCKDB_TYPE_USMALLINT, DUCKDB_TYPE_UINTEGER, DUCKDB_TYPE_UBIGINT, DUCKDB_TYPE_UHUGEINT, + DUCKDB_TYPE_FLOAT, DUCKDB_TYPE_DOUBLE, DUCKDB_TYPE_DECIMAL, + DUCKDB_TYPE_VARCHAR, DUCKDB_TYPE_BLOB, DUCKDB_TYPE_UUID, DUCKDB_TYPE_BIT, DUCKDB_TYPE_ENUM, + DUCKDB_TYPE_DATE, DUCKDB_TYPE_TIME, DUCKDB_TYPE_TIME_NS, DUCKDB_TYPE_INTERVAL, + DUCKDB_TYPE_TIMESTAMP, DUCKDB_TYPE_TIMESTAMP_S, DUCKDB_TYPE_TIMESTAMP_MS, DUCKDB_TYPE_TIMESTAMP_NS, + DUCKDB_TYPE_LIST, DUCKDB_TYPE_STRUCT, DUCKDB_TYPE_MAP, DUCKDB_TYPE_ARRAY, DUCKDB_TYPE_UNION: return true default: return false } } + static func requiresTextCast(_ type: duckdb_type) -> Bool { + !isNativelyRenderable(type) + } + static func castExpression(for type: duckdb_type, column: String) -> String { let quoted = quoteIdentifier(column) - switch type { - case DUCKDB_TYPE_GEOMETRY: + if type == DUCKDB_TYPE_GEOMETRY { return "CASE WHEN \(quoted) IS NULL THEN NULL ELSE ST_AsText(\(quoted)) END AS \(quoted)" - case DUCKDB_TYPE_INVALID, 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 } + return "CASE WHEN \(quoted) IS NULL THEN NULL ELSE CAST(\(quoted) AS VARCHAR) END AS \(quoted)" + } + + static func projection(for type: duckdb_type, column: String) -> String { + requiresTextCast(type) ? castExpression(for: type, column: column) : quoteIdentifier(column) } static func buildWrappedQuery(originalQuery: String, castExprs: [String]) -> String {