Skip to content

Commit b215225

Browse files
committed
feat(plugin-duckdb): add experimental remote Quack protocol connections
1 parent f60228e commit b215225

20 files changed

Lines changed: 702 additions & 47 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- 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)
1313
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation
1414
- An Add button in the table status bar inserts a new row at the end of the grid and starts editing it.
15+
- 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)
1516

1617
### Changed
1718

Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,70 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin {
1717
static let databaseTypeId = "DuckDB"
1818
static let databaseDisplayName = "DuckDB"
1919
static let iconName = "duckdb-icon"
20-
static let defaultPort = 0
20+
static let defaultPort = 9_494
2121

2222
// MARK: - UI/Capability Metadata
2323

2424
static let isDownloadable = true
25-
static let pathFieldRole: PathFieldRole = .filePath
25+
static let pathFieldRole: PathFieldRole = .database
2626
static let requiresAuthentication = false
27-
static let connectionMode: ConnectionMode = .fileBased
28-
static let urlSchemes: [String] = ["duckdb"]
27+
static let connectionMode: ConnectionMode = .apiOnly
28+
static let urlSchemes: [String] = ["duckdb", "quack"]
29+
30+
static let additionalConnectionFields: [ConnectionField] = [
31+
ConnectionField(
32+
id: "duckdbMode",
33+
label: String(localized: "Connection Type"),
34+
defaultValue: "local",
35+
fieldType: .dropdown(options: [
36+
ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
37+
ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
38+
]),
39+
section: .authentication
40+
),
41+
ConnectionField(
42+
id: "duckdbFilePath",
43+
label: String(localized: "Database File"),
44+
placeholder: "/path/to/database.duckdb",
45+
required: true,
46+
section: .authentication,
47+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
48+
),
49+
ConnectionField(
50+
id: "duckdbHost",
51+
label: String(localized: "Host"),
52+
placeholder: "localhost",
53+
required: true,
54+
section: .authentication,
55+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
56+
),
57+
ConnectionField(
58+
id: "duckdbPort",
59+
label: String(localized: "Port"),
60+
placeholder: "9494",
61+
defaultValue: "9494",
62+
fieldType: .number,
63+
section: .authentication,
64+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
65+
),
66+
ConnectionField(
67+
id: "duckdbToken",
68+
label: String(localized: "Token"),
69+
fieldType: .secure,
70+
section: .authentication,
71+
hidesPassword: true,
72+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
73+
),
74+
ConnectionField(
75+
id: "duckdbAlias",
76+
label: String(localized: "Database Alias"),
77+
placeholder: "remotedb",
78+
required: true,
79+
defaultValue: "remotedb",
80+
section: .authentication,
81+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
82+
)
83+
]
2984
static let fileExtensions: [String] = ["duckdb", "ddb"]
3085
static let brandColorHex = "#FFD900"
3186
static let supportsDatabaseSwitching = false
@@ -147,8 +202,27 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
147202

148203
// MARK: - Connection
149204

205+
private var isRemoteMode: Bool {
206+
config.additionalFields["duckdbMode"] == "remote"
207+
}
208+
209+
private var remoteAlias: String? {
210+
guard isRemoteMode else { return nil }
211+
let alias = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
212+
return alias.isEmpty ? "remotedb" : alias
213+
}
214+
150215
func connect() async throws {
151-
let path = expandPath(config.database)
216+
if isRemoteMode {
217+
try await connectRemote()
218+
} else {
219+
try await connectLocal()
220+
}
221+
}
222+
223+
private func connectLocal() async throws {
224+
let rawPath = config.additionalFields["duckdbFilePath"].flatMap { $0.isEmpty ? nil : $0 } ?? config.database
225+
let path = expandPath(rawPath)
152226

153227
if !FileManager.default.fileExists(atPath: path) {
154228
let directory = (path as NSString).deletingLastPathComponent
@@ -161,15 +235,66 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
161235
}
162236

163237
try await connectionActor.open(path: path)
238+
await enableExtensionAutoloading()
239+
await captureInterruptHandle()
240+
}
241+
242+
private func connectRemote() async throws {
243+
let host = (config.additionalFields["duckdbHost"] ?? "").trimmingCharacters(in: .whitespaces)
244+
let aliasInput = (config.additionalFields["duckdbAlias"] ?? "").trimmingCharacters(in: .whitespaces)
245+
let portInput = config.additionalFields["duckdbPort"] ?? ""
246+
let token = config.additionalFields["duckdbToken"] ?? ""
164247

165-
// Enable auto-install and auto-load of extensions (e.g. core_functions)
248+
guard QuackConnectBuilder.isValidHost(host) else {
249+
throw DuckDBPluginError.connectionFailed(
250+
String(localized: "Host is required for a remote DuckDB connection")
251+
)
252+
}
253+
guard let port = QuackConnectBuilder.normalizedPort(portInput) else {
254+
throw DuckDBPluginError.connectionFailed(
255+
String(localized: "Port must be a number between 1 and 65535")
256+
)
257+
}
258+
let alias = aliasInput.isEmpty ? "remotedb" : aliasInput
259+
260+
try await connectionActor.open(path: ":memory:")
261+
await enableExtensionAutoloading()
262+
await loadQuackExtension()
263+
264+
if !token.isEmpty {
265+
try await connectionActor.executeQuery(QuackConnectBuilder.secretSQL(token: token))
266+
}
267+
268+
try await connectionActor.executeQuery(QuackConnectBuilder.attachSQL(host: host, port: port, alias: alias))
269+
try await connectionActor.executeQuery(QuackConnectBuilder.useSQL(alias: alias))
270+
271+
stateLock.lock()
272+
_currentSchema = "main"
273+
stateLock.unlock()
274+
275+
await captureInterruptHandle()
276+
}
277+
278+
private func enableExtensionAutoloading() async {
166279
do {
167280
try await connectionActor.executeQuery("SET autoinstall_known_extensions=1")
168281
try await connectionActor.executeQuery("SET autoload_known_extensions=1")
169282
} catch {
170283
Self.logger.warning("Failed to enable DuckDB extension autoloading: \(error.localizedDescription)")
171284
}
285+
}
172286

287+
private func loadQuackExtension() async {
288+
for statement in ["INSTALL quack", "LOAD quack"] {
289+
do {
290+
try await connectionActor.executeQuery(statement)
291+
} catch {
292+
Self.logger.warning("DuckDB '\(statement)' failed: \(error.localizedDescription)")
293+
}
294+
}
295+
}
296+
297+
private func captureInterruptHandle() async {
173298
if let conn = await connectionActor.connectionHandleForInterrupt {
174299
setInterruptHandle(conn)
175300
}
@@ -592,6 +717,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
592717

593718
func fetchSchemas() async throws -> [String] {
594719
let query = "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name"
720+
if let remoteAlias {
721+
let schemas = (try? await execute(query: query))?.rows.compactMap { $0[safe: 0]?.asText } ?? []
722+
return schemas.isEmpty ? ["main"] : schemas
723+
}
595724
let result = try await execute(query: query)
596725
return result.rows.compactMap { $0[safe: 0]?.asText }
597726
}
@@ -607,6 +736,9 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
607736
// MARK: - Database Operations
608737

609738
func fetchDatabases() async throws -> [String] {
739+
if let remoteAlias {
740+
return [remoteAlias]
741+
}
610742
let query = "SELECT database_name FROM duckdb_databases() ORDER BY database_name"
611743
let result = try await execute(query: query)
612744
return result.rows.compactMap { row in
@@ -876,4 +1008,3 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
8761008
}
8771009
}
8781010
}
879-
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// QuackConnectBuilder.swift
3+
// DuckDBDriverPlugin
4+
//
5+
6+
import Foundation
7+
8+
enum QuackConnectBuilder {
9+
static let defaultPort = 9_494
10+
11+
static func isValidHost(_ host: String) -> Bool {
12+
!host.trimmingCharacters(in: .whitespaces).isEmpty
13+
}
14+
15+
static func normalizedPort(_ raw: String) -> Int? {
16+
let trimmed = raw.trimmingCharacters(in: .whitespaces)
17+
if trimmed.isEmpty { return defaultPort }
18+
guard let port = Int(trimmed), (1...65_535).contains(port) else { return nil }
19+
return port
20+
}
21+
22+
static func secretSQL(token: String) -> String {
23+
"CREATE OR REPLACE SECRET (TYPE quack, TOKEN '\(escapeLiteral(token))')"
24+
}
25+
26+
static func attachSQL(host: String, port: Int, alias: String) -> String {
27+
"ATTACH '\(quackTarget(host: host, port: port))' AS \(quoteIdentifier(alias))"
28+
}
29+
30+
static func useSQL(alias: String) -> String {
31+
"USE \(quoteIdentifier(alias))"
32+
}
33+
34+
private static func quackTarget(host: String, port: Int) -> String {
35+
"quack:\(escapeLiteral(host)):\(port)"
36+
}
37+
38+
private static func escapeLiteral(_ value: String) -> String {
39+
value
40+
.replacingOccurrences(of: "\0", with: "")
41+
.replacingOccurrences(of: "'", with: "''")
42+
}
43+
44+
private static func quoteIdentifier(_ name: String) -> String {
45+
"\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
46+
}
47+
}

TablePro.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
6666
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
6767
5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; };
68+
5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A869000100000000 /* DuckDBDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
6869
5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; };
6970
5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; };
7071
5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; };
@@ -287,6 +288,7 @@
287288
dstPath = "";
288289
dstSubfolderSpec = 13;
289290
files = (
291+
5ACE252A2FE4508500357377 /* DuckDBDriver.tableplugin in Copy Plug-Ins (12 items) */,
290292
5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
291293
5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */,
292294
5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// PluginMetadataRegistry+DuckDBConnectionFields.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
extension PluginMetadataRegistry {
10+
static var duckdbConnectionFields: [ConnectionField] {
11+
[
12+
ConnectionField(
13+
id: "duckdbMode",
14+
label: String(localized: "Connection Type"),
15+
defaultValue: "local",
16+
fieldType: .dropdown(options: [
17+
ConnectionField.DropdownOption(value: "local", label: String(localized: "Local File")),
18+
ConnectionField.DropdownOption(value: "remote", label: String(localized: "Remote (Quack, experimental)"))
19+
]),
20+
section: .authentication
21+
),
22+
ConnectionField(
23+
id: "duckdbFilePath",
24+
label: String(localized: "Database File"),
25+
placeholder: "/path/to/database.duckdb",
26+
required: true,
27+
section: .authentication,
28+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])
29+
),
30+
ConnectionField(
31+
id: "duckdbHost",
32+
label: String(localized: "Host"),
33+
placeholder: "localhost",
34+
required: true,
35+
section: .authentication,
36+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
37+
),
38+
ConnectionField(
39+
id: "duckdbPort",
40+
label: String(localized: "Port"),
41+
placeholder: "9494",
42+
defaultValue: "9494",
43+
fieldType: .number,
44+
section: .authentication,
45+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
46+
),
47+
ConnectionField(
48+
id: "duckdbToken",
49+
label: String(localized: "Token"),
50+
fieldType: .secure,
51+
section: .authentication,
52+
hidesPassword: true,
53+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
54+
),
55+
ConnectionField(
56+
id: "duckdbAlias",
57+
label: String(localized: "Database Alias"),
58+
placeholder: "remotedb",
59+
required: true,
60+
defaultValue: "remotedb",
61+
section: .authentication,
62+
visibleWhen: FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"])
63+
)
64+
]
65+
}
66+
}

TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ extension PluginMetadataRegistry {
285285
"Enum": ["ENUM"]
286286
]
287287

288+
let duckdbConnectionFields = Self.duckdbConnectionFields
289+
288290
let cassandraDialect = SQLDialectDescriptor(
289291
identifierQuote: "\"",
290292
keywords: [
@@ -811,18 +813,18 @@ extension PluginMetadataRegistry {
811813
)
812814
)),
813815
("DuckDB", PluginMetadataSnapshot(
814-
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 0,
816+
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
815817
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
816818
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
817819
navigationModel: .standard,
818820
explainVariants: [
819821
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN"),
820822
],
821-
pathFieldRole: .filePath,
822-
supportsHealthMonitor: false, urlSchemes: ["duckdb"], postConnectActions: [],
823+
pathFieldRole: .database,
824+
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"], postConnectActions: [],
823825
brandColorHex: "#FFD900",
824826
queryLanguageName: "SQL", editorLanguage: .sql,
825-
connectionMode: .fileBased, supportsDatabaseSwitching: false,
827+
connectionMode: .apiOnly, supportsDatabaseSwitching: false,
826828
supportsColumnReorder: false,
827829
capabilities: PluginMetadataSnapshot.CapabilityFlags(
828830
supportsSchemaSwitching: false,
@@ -857,8 +859,9 @@ extension PluginMetadataRegistry {
857859
columnTypesByCategory: duckdbColumnTypes
858860
),
859861
connection: PluginMetadataSnapshot.ConnectionConfig(
862+
additionalConnectionFields: duckdbConnectionFields,
860863
category: .analytical,
861-
tagline: String(localized: "Embedded analytical SQL")
864+
tagline: String(localized: "Embedded and remote analytical SQL")
862865
)
863866
)),
864867
("Cassandra", PluginMetadataSnapshot(

0 commit comments

Comments
 (0)