@@ -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-
0 commit comments