Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695)
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation
- An Add button in the table status bar inserts a new row at the end of the grid and starts editing it.
- DuckDB connections can now reach a remote server over the Quack protocol (experimental). Pick Remote (Quack) in the connection form, enter the host, port, and token, then run SQL against the remote. Listing remote tables in the sidebar is not available yet because the Quack beta cannot enumerate a remote catalog. Needs DuckDB 1.5.3 or later. (#1716)

### Changed

Expand Down
145 changes: 138 additions & 7 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,70 @@
static let databaseTypeId = "DuckDB"
static let databaseDisplayName = "DuckDB"
static let iconName = "duckdb-icon"
static let defaultPort = 0
static let defaultPort = 9_494

// MARK: - UI/Capability Metadata

static let isDownloadable = true
static let pathFieldRole: PathFieldRole = .filePath
static let pathFieldRole: PathFieldRole = .database
static let requiresAuthentication = false
static let connectionMode: ConnectionMode = .fileBased
static let urlSchemes: [String] = ["duckdb"]
static let connectionMode: ConnectionMode = .apiOnly
static let urlSchemes: [String] = ["duckdb", "quack"]

static let additionalConnectionFields: [ConnectionField] = [
ConnectionField(
id: "duckdbMode",
label: String(localized: "Connection Type"),
defaultValue: "local",
fieldType: .dropdown(options: [
ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
]),
section: .authentication
),
ConnectionField(
id: "duckdbFilePath",
label: String(localized: "Database File"),
placeholder: "/path/to/database.duckdb",
required: true,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
),
ConnectionField(
id: "duckdbHost",
label: String(localized: "Host"),
placeholder: "localhost",
required: true,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbPort",
label: String(localized: "Port"),
placeholder: "9494",
defaultValue: "9494",
fieldType: .number,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbToken",
label: String(localized: "Token"),
fieldType: .secure,
section: .authentication,
hidesPassword: true,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbAlias",
label: String(localized: "Database Alias"),
placeholder: "remotedb",
required: true,
defaultValue: "remotedb",
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
)
]
static let fileExtensions: [String] = ["duckdb", "ddb"]
static let brandColorHex = "#FFD900"
static let supportsDatabaseSwitching = false
Expand Down Expand Up @@ -147,8 +202,27 @@

// MARK: - Connection

private var isRemoteMode: Bool {
config.additionalFields["duckdbMode"] == "remote"
}

private var remoteAlias: String? {
guard isRemoteMode else { return nil }
let alias = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
return alias.isEmpty ? "remotedb" : alias
}

func connect() async throws {
let path = expandPath(config.database)
if isRemoteMode {
try await connectRemote()
} else {
try await connectLocal()
}
}

private func connectLocal() async throws {
let rawPath = config.additionalFields["duckdbFilePath"].flatMap { $0.isEmpty ? nil : $0 } ?? config.database
let path = expandPath(rawPath)

if !FileManager.default.fileExists(atPath: path) {
let directory = (path as NSString).deletingLastPathComponent
Expand All @@ -161,15 +235,66 @@
}

try await connectionActor.open(path: path)
await enableExtensionAutoloading()
await captureInterruptHandle()
}

private func connectRemote() async throws {
let host = (config.additionalFields["duckdbHost"] ?? "").trimmingCharacters(in: .whitespaces)
let aliasInput = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
let portInput = config.additionalFields["duckdbPort"] ?? ""
let token = config.additionalFields["duckdbToken"] ?? ""

// Enable auto-install and auto-load of extensions (e.g. core_functions)
guard QuackConnectBuilder.isValidHost(host) else {
throw DuckDBPluginError.connectionFailed(
String(localized: "Host is required for a remote DuckDB connection")
)
}
guard let port = QuackConnectBuilder.normalizedPort(portInput) else {
throw DuckDBPluginError.connectionFailed(
String(localized: "Port must be a number between 1 and 65535")
)
}
let alias = aliasInput.isEmpty ? "remotedb" : aliasInput

try await connectionActor.open(path: ":memory:")
await enableExtensionAutoloading()
await loadQuackExtension()

if !token.isEmpty {
try await connectionActor.executeQuery(QuackConnectBuilder.secretSQL(token: token))

Check warning on line 265 in Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

result of call to 'executeQuery' is unused
}

try await connectionActor.executeQuery(QuackConnectBuilder.attachSQL(host: host, port: port, alias: alias))

Check warning on line 268 in Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

result of call to 'executeQuery' is unused
try await connectionActor.executeQuery(QuackConnectBuilder.useSQL(alias: alias))

Check warning on line 269 in Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

result of call to 'executeQuery' is unused

stateLock.lock()
_currentSchema = "main"
stateLock.unlock()

await captureInterruptHandle()
}

private func enableExtensionAutoloading() async {
do {
try await connectionActor.executeQuery("SET autoinstall_known_extensions=1")
try await connectionActor.executeQuery("SET autoload_known_extensions=1")
} catch {
Self.logger.warning("Failed to enable DuckDB extension autoloading: \(error.localizedDescription)")
}
}

private func loadQuackExtension() async {
for statement in ["INSTALL quack", "LOAD quack"] {
do {
try await connectionActor.executeQuery(statement)
} catch {
Self.logger.warning("DuckDB '\(statement)' failed: \(error.localizedDescription)")
}
}
}

private func captureInterruptHandle() async {
if let conn = await connectionActor.connectionHandleForInterrupt {
setInterruptHandle(conn)
}
Expand Down Expand Up @@ -592,6 +717,10 @@

func fetchSchemas() async throws -> [String] {
let query = "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name"
if let remoteAlias {
let schemas = (try? await execute(query: query))?.rows.compactMap { $0[safe: 0]?.asText } ?? []
return schemas.isEmpty ? ["main"] : schemas
}
let result = try await execute(query: query)
return result.rows.compactMap { $0[safe: 0]?.asText }
}
Expand All @@ -607,6 +736,9 @@
// MARK: - Database Operations

func fetchDatabases() async throws -> [String] {
if let remoteAlias {
return [remoteAlias]
}
let query = "SELECT database_name FROM duckdb_databases() ORDER BY database_name"
let result = try await execute(query: query)
return result.rows.compactMap { row in
Expand Down Expand Up @@ -876,4 +1008,3 @@
}
}
}

47 changes: 47 additions & 0 deletions Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// QuackConnectBuilder.swift
// DuckDBDriverPlugin
//

import Foundation

enum QuackConnectBuilder {
static let defaultPort = 9_494

static func isValidHost(_ host: String) -> Bool {
!host.trimmingCharacters(in: .whitespaces).isEmpty
}

static func normalizedPort(_ raw: String) -> Int? {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return defaultPort }
guard let port = Int(trimmed), (1...65_535).contains(port) else { return nil }
return port
}

static func secretSQL(token: String) -> String {
"CREATE OR REPLACE SECRET (TYPE quack, TOKEN '\(escapeLiteral(token))')"
}

static func attachSQL(host: String, port: Int, alias: String) -> String {
"ATTACH '\(quackTarget(host: host, port: port))' AS \(quoteIdentifier(alias))"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support plain-HTTP Quack remotes

When the host is non-local but the server is exposed as plain HTTP (for example CALL quack_serve('quack:0.0.0.0:9494', allow_other_hostname => true) on a LAN/dev box), DuckDB's Quack client assumes HTTPS unless the ATTACH includes DISABLE_SSL true (DuckDB docs). This builder never emits that option and the new fields do not expose any way to request it, so those remote connections fail at attach time even with the correct host, port, and token.

Useful? React with 👍 / 👎.

}

static func useSQL(alias: String) -> String {
"USE \(quoteIdentifier(alias))"
}

private static func quackTarget(host: String, port: Int) -> String {
"quack:\(escapeLiteral(host)):\(port)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve IPv6 brackets in Quack targets

For an IPv6 host parsed from a URL such as quack://[::1]:9494/remotedb or entered as ::1, the stored host value is ::1, but this constructs quack:::1:9494 instead of the bracketed Quack URI form quack:[::1]:9494 that DuckDB documents for IPv6 endpoints. Those connections cannot be parsed by Quack; wrap colon-containing hosts in brackets before building the target.

Useful? React with 👍 / 👎.

}

private static func escapeLiteral(_ value: String) -> String {
value
.replacingOccurrences(of: "\0", with: "")
.replacingOccurrences(of: "'", with: "''")
}

private static func quoteIdentifier(_ name: String) -> String {
"\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
}
}
2 changes: 2 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A869000100000000 /* DuckDBDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; };
5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; };
5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; };
Expand Down Expand Up @@ -287,6 +288,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// PluginMetadataRegistry+DuckDBConnectionFields.swift
// TablePro
//

import Foundation
import TableProPluginKit

extension PluginMetadataRegistry {
static var duckdbConnectionFields: [ConnectionField] {
[
ConnectionField(
id: "duckdbMode",
label: String(localized: "Connection Type"),
defaultValue: "local",
fieldType: .dropdown(options: [
ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
]),
section: .authentication
),
ConnectionField(
id: "duckdbFilePath",
label: String(localized: "Database File"),
placeholder: "/path/to/database.duckdb",
required: true,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
),
ConnectionField(
id: "duckdbHost",
label: String(localized: "Host"),
placeholder: "localhost",
required: true,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbPort",
label: String(localized: "Port"),
placeholder: "9494",
defaultValue: "9494",
fieldType: .number,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbToken",
label: String(localized: "Token"),
fieldType: .secure,
section: .authentication,
hidesPassword: true,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
),
ConnectionField(
id: "duckdbAlias",
label: String(localized: "Database Alias"),
placeholder: "remotedb",
required: true,
defaultValue: "remotedb",
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
)
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ extension PluginMetadataRegistry {
"Enum": ["ENUM"]
]

let duckdbConnectionFields = Self.duckdbConnectionFields

let cassandraDialect = SQLDialectDescriptor(
identifierQuote: "\"",
keywords: [
Expand Down Expand Up @@ -811,18 +813,18 @@ extension PluginMetadataRegistry {
)
)),
("DuckDB", PluginMetadataSnapshot(
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 0,
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
],
pathFieldRole: .filePath,
supportsHealthMonitor: false, urlSchemes: ["duckdb"], postConnectActions: [],
pathFieldRole: .database,
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], postConnectActions: [],
brandColorHex: "#FFD900",
queryLanguageName: "SQL", editorLanguage: .sql,
connectionMode: .fileBased, supportsDatabaseSwitching: false,
connectionMode: .apiOnly, supportsDatabaseSwitching: false,
supportsColumnReorder: false,
capabilities: PluginMetadataSnapshot.CapabilityFlags(
supportsSchemaSwitching: false,
Expand Down Expand Up @@ -857,8 +859,9 @@ extension PluginMetadataRegistry {
columnTypesByCategory: duckdbColumnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
additionalConnectionFields: duckdbConnectionFields,
category: .analytical,
tagline: String(localized: "Embedded analytical SQL")
tagline: String(localized: "Embedded and remote analytical SQL")
)
)),
("Cassandra", PluginMetadataSnapshot(
Expand Down
Loading
Loading