From d364754f8101229bf3630244370bc5009318153f Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:29:17 +0100 Subject: [PATCH 1/6] feat: add MotherDuck cloud DuckDB support Add a new database provider for MotherDuck, the cloud-hosted DuckDB service. This adds MotherDuck as a separate connection type with its own form fields for database name and access token. - Add MOTHERDUCK to DatabaseType enum - Create motherduck provider with adapter inheriting from DuckDBAdapter - Support md: connection strings with optional database and token - Add unit tests for connection string building and provider registration --- sqlit/domains/connections/domain/config.py | 2 + .../providers/motherduck/__init__.py | 1 + .../providers/motherduck/adapter.py | 47 +++++++ .../providers/motherduck/provider.py | 38 +++++ .../providers/motherduck/schema.py | 32 +++++ tests/unit/test_motherduck_adapter.py | 131 ++++++++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 sqlit/domains/connections/providers/motherduck/__init__.py create mode 100644 sqlit/domains/connections/providers/motherduck/adapter.py create mode 100644 sqlit/domains/connections/providers/motherduck/provider.py create mode 100644 sqlit/domains/connections/providers/motherduck/schema.py create mode 100644 tests/unit/test_motherduck_adapter.py diff --git a/sqlit/domains/connections/domain/config.py b/sqlit/domains/connections/domain/config.py index ed78e0f..c2eba8f 100644 --- a/sqlit/domains/connections/domain/config.py +++ b/sqlit/domains/connections/domain/config.py @@ -20,6 +20,7 @@ class DatabaseType(str, Enum): FLIGHT = "flight" HANA = "hana" MARIADB = "mariadb" + MOTHERDUCK = "motherduck" MSSQL = "mssql" MYSQL = "mysql" ORACLE = "oracle" @@ -52,6 +53,7 @@ class DatabaseType(str, Enum): DatabaseType.TRINO, DatabaseType.PRESTO, DatabaseType.DUCKDB, + DatabaseType.MOTHERDUCK, DatabaseType.REDSHIFT, DatabaseType.CLICKHOUSE, DatabaseType.COCKROACHDB, diff --git a/sqlit/domains/connections/providers/motherduck/__init__.py b/sqlit/domains/connections/providers/motherduck/__init__.py new file mode 100644 index 0000000..1f91a7c --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/__init__.py @@ -0,0 +1 @@ +"""MotherDuck provider package.""" diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py new file mode 100644 index 0000000..5297d43 --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -0,0 +1,47 @@ +"""MotherDuck adapter for cloud DuckDB.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlit.domains.connections.providers.duckdb.adapter import DuckDBAdapter + +if TYPE_CHECKING: + from sqlit.domains.connections.domain.config import ConnectionConfig + + +class MotherDuckAdapter(DuckDBAdapter): + """Adapter for MotherDuck cloud DuckDB service.""" + + @property + def name(self) -> str: + return "MotherDuck" + + @property + def supports_process_worker(self) -> bool: + """MotherDuck handles concurrency server-side.""" + return True + + def connect(self, config: ConnectionConfig) -> Any: + """Connect to MotherDuck cloud database. + + Connection string format: md:[database_name][?motherduck_token=TOKEN] + If no token is provided, browser-based authentication is used. + """ + duckdb = self._import_driver_module( + "duckdb", + driver_name=self.name, + extra_name=self.install_extra, + package_name=self.install_package, + ) + + # Build MotherDuck connection string + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + duckdb_any: Any = duckdb + return duckdb_any.connect(conn_str) diff --git a/sqlit/domains/connections/providers/motherduck/provider.py b/sqlit/domains/connections/providers/motherduck/provider.py new file mode 100644 index 0000000..f4edf6d --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/provider.py @@ -0,0 +1,38 @@ +"""Provider registration for MotherDuck.""" + +from sqlit.domains.connections.providers.adapter_provider import build_adapter_provider +from sqlit.domains.connections.providers.catalog import register_provider +from sqlit.domains.connections.providers.model import DatabaseProvider, ProviderSpec +from sqlit.domains.connections.providers.motherduck.schema import SCHEMA + + +def _display_info(config) -> str: + """Display info for MotherDuck connections.""" + database = config.get_option("database", "") or config.database or "" + if database: + return f"md:{database}" + return "md: (default)" + + +def _provider_factory(spec: ProviderSpec) -> DatabaseProvider: + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + return build_adapter_provider(spec, SCHEMA, MotherDuckAdapter()) + + +SPEC = ProviderSpec( + db_type="motherduck", + display_name="MotherDuck", + schema_path=("sqlit.domains.connections.providers.motherduck.schema", "SCHEMA"), + supports_ssh=False, + is_file_based=False, + has_advanced_auth=False, + default_port="", + requires_auth=False, + badge_label="MotherDuck", + url_schemes=("motherduck", "md"), + provider_factory=_provider_factory, + display_info=_display_info, +) + +register_provider(SPEC) diff --git a/sqlit/domains/connections/providers/motherduck/schema.py b/sqlit/domains/connections/providers/motherduck/schema.py new file mode 100644 index 0000000..5be794b --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/schema.py @@ -0,0 +1,32 @@ +"""Connection schema for MotherDuck.""" + +from sqlit.domains.connections.providers.schema_helpers import ( + ConnectionSchema, + FieldType, + SchemaField, +) + +SCHEMA = ConnectionSchema( + db_type="motherduck", + display_name="MotherDuck", + fields=( + SchemaField( + name="database", + label="Database", + placeholder="my_database (optional)", + required=False, + description="MotherDuck database name. Leave empty to use default.", + ), + SchemaField( + name="motherduck_token", + label="Access Token", + field_type=FieldType.PASSWORD, + placeholder="(optional - uses browser auth if empty)", + required=False, + description="MotherDuck access token for non-interactive authentication.", + ), + ), + supports_ssh=False, + is_file_based=False, + requires_auth=False, +) diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py new file mode 100644 index 0000000..73e27e7 --- /dev/null +++ b/tests/unit/test_motherduck_adapter.py @@ -0,0 +1,131 @@ +"""Unit tests for MotherDuck adapter.""" + +from __future__ import annotations + +import pytest + +from sqlit.domains.connections.domain.config import ConnectionConfig, TcpEndpoint + + +class TestMotherDuckConnectionString: + """Test MotherDuck connection string building.""" + + def test_basic_connection_string(self): + """Test basic md: connection string.""" + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + adapter = MotherDuckAdapter() + config = ConnectionConfig( + name="test", + db_type="motherduck", + endpoint=TcpEndpoint(), + ) + + # We can't actually connect without duckdb/motherduck, but we can test + # that the adapter builds the correct connection string + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + assert conn_str == "md:" + + def test_connection_string_with_database(self): + """Test md:database connection string.""" + config = ConnectionConfig( + name="test", + db_type="motherduck", + endpoint=TcpEndpoint(database="my_database"), + ) + + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + assert conn_str == "md:my_database" + + def test_connection_string_with_database_in_options(self): + """Test database from options.""" + config = ConnectionConfig( + name="test", + db_type="motherduck", + endpoint=TcpEndpoint(), + options={"database": "options_database"}, + ) + + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + assert conn_str == "md:options_database" + + def test_connection_string_with_token(self): + """Test md: with token.""" + config = ConnectionConfig( + name="test", + db_type="motherduck", + endpoint=TcpEndpoint(), + options={"motherduck_token": "my_secret_token"}, + ) + + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + assert conn_str == "md:?motherduck_token=my_secret_token" + + def test_connection_string_with_database_and_token(self): + """Test md:database?token connection string.""" + config = ConnectionConfig( + name="test", + db_type="motherduck", + endpoint=TcpEndpoint(database="prod_db"), + options={"motherduck_token": "my_token"}, + ) + + database = config.get_option("database", "") or config.database or "" + token = config.get_option("motherduck_token", "") + + conn_str = f"md:{database}" if database else "md:" + if token: + conn_str += f"?motherduck_token={token}" + + assert conn_str == "md:prod_db?motherduck_token=my_token" + + +def test_motherduck_provider_registered(): + """Test that MotherDuck provider is properly registered.""" + from sqlit.domains.connections.providers.catalog import get_supported_db_types + + db_types = get_supported_db_types() + assert "motherduck" in db_types + + +def test_motherduck_provider_metadata(): + """Test MotherDuck provider metadata.""" + from sqlit.domains.connections.providers.catalog import get_provider + + provider = get_provider("motherduck") + assert provider.metadata.display_name == "MotherDuck" + assert provider.metadata.is_file_based is False + assert provider.metadata.supports_ssh is False + assert "md" in provider.metadata.url_schemes + assert "motherduck" in provider.metadata.url_schemes + + +def test_motherduck_database_type_enum(): + """Test MotherDuck is in DatabaseType enum.""" + from sqlit.domains.connections.domain.config import DatabaseType + + assert DatabaseType.MOTHERDUCK.value == "motherduck" From ed9392f4f6c85177c174751f9594c5245530755f Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:51:01 +0100 Subject: [PATCH 2/6] fix: require database and token for MotherDuck connections - Make database and access token required fields - Use file-based URL parsing for motherduck:///database?token=xxx format - Update tests for new requirements --- .../providers/motherduck/adapter.py | 27 ++-- .../providers/motherduck/provider.py | 4 +- .../providers/motherduck/schema.py | 15 +-- tests/unit/test_motherduck_adapter.py | 126 ++++-------------- 4 files changed, 49 insertions(+), 123 deletions(-) diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py index 5297d43..255530f 100644 --- a/sqlit/domains/connections/providers/motherduck/adapter.py +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -23,11 +23,7 @@ def supports_process_worker(self) -> bool: return True def connect(self, config: ConnectionConfig) -> Any: - """Connect to MotherDuck cloud database. - - Connection string format: md:[database_name][?motherduck_token=TOKEN] - If no token is provided, browser-based authentication is used. - """ + """Connect to MotherDuck cloud database.""" duckdb = self._import_driver_module( "duckdb", driver_name=self.name, @@ -35,13 +31,22 @@ def connect(self, config: ConnectionConfig) -> Any: package_name=self.install_package, ) - # Build MotherDuck connection string - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") + # Get database from file_path + database = "" + if config.file_endpoint and config.file_endpoint.path: + database = config.file_endpoint.path.lstrip("/") + + # Get token from extra_options (URL) or options (UI) + token = config.extra_options.get("motherduck_token", "") + if not token: + token = config.get_option("motherduck_token", "") + + if not database: + raise ValueError("MotherDuck connections require a database name.") + if not token: + raise ValueError("MotherDuck connections require an access token.") - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" + conn_str = f"md:{database}?motherduck_token={token}" duckdb_any: Any = duckdb return duckdb_any.connect(conn_str) diff --git a/sqlit/domains/connections/providers/motherduck/provider.py b/sqlit/domains/connections/providers/motherduck/provider.py index f4edf6d..6776b57 100644 --- a/sqlit/domains/connections/providers/motherduck/provider.py +++ b/sqlit/domains/connections/providers/motherduck/provider.py @@ -25,10 +25,10 @@ def _provider_factory(spec: ProviderSpec) -> DatabaseProvider: display_name="MotherDuck", schema_path=("sqlit.domains.connections.providers.motherduck.schema", "SCHEMA"), supports_ssh=False, - is_file_based=False, + is_file_based=True, # Use file-based URL parsing (motherduck:///database) has_advanced_auth=False, default_port="", - requires_auth=False, + requires_auth=True, badge_label="MotherDuck", url_schemes=("motherduck", "md"), provider_factory=_provider_factory, diff --git a/sqlit/domains/connections/providers/motherduck/schema.py b/sqlit/domains/connections/providers/motherduck/schema.py index 5be794b..a53764a 100644 --- a/sqlit/domains/connections/providers/motherduck/schema.py +++ b/sqlit/domains/connections/providers/motherduck/schema.py @@ -11,22 +11,19 @@ display_name="MotherDuck", fields=( SchemaField( - name="database", + name="file_path", label="Database", - placeholder="my_database (optional)", - required=False, - description="MotherDuck database name. Leave empty to use default.", + placeholder="my_database", + required=True, ), SchemaField( name="motherduck_token", label="Access Token", field_type=FieldType.PASSWORD, - placeholder="(optional - uses browser auth if empty)", - required=False, - description="MotherDuck access token for non-interactive authentication.", + required=True, ), ), supports_ssh=False, - is_file_based=False, - requires_auth=False, + is_file_based=True, + requires_auth=True, ) diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py index 73e27e7..2539dfb 100644 --- a/tests/unit/test_motherduck_adapter.py +++ b/tests/unit/test_motherduck_adapter.py @@ -2,106 +2,7 @@ from __future__ import annotations -import pytest - -from sqlit.domains.connections.domain.config import ConnectionConfig, TcpEndpoint - - -class TestMotherDuckConnectionString: - """Test MotherDuck connection string building.""" - - def test_basic_connection_string(self): - """Test basic md: connection string.""" - from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter - - adapter = MotherDuckAdapter() - config = ConnectionConfig( - name="test", - db_type="motherduck", - endpoint=TcpEndpoint(), - ) - - # We can't actually connect without duckdb/motherduck, but we can test - # that the adapter builds the correct connection string - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") - - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" - - assert conn_str == "md:" - - def test_connection_string_with_database(self): - """Test md:database connection string.""" - config = ConnectionConfig( - name="test", - db_type="motherduck", - endpoint=TcpEndpoint(database="my_database"), - ) - - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") - - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" - - assert conn_str == "md:my_database" - - def test_connection_string_with_database_in_options(self): - """Test database from options.""" - config = ConnectionConfig( - name="test", - db_type="motherduck", - endpoint=TcpEndpoint(), - options={"database": "options_database"}, - ) - - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") - - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" - - assert conn_str == "md:options_database" - - def test_connection_string_with_token(self): - """Test md: with token.""" - config = ConnectionConfig( - name="test", - db_type="motherduck", - endpoint=TcpEndpoint(), - options={"motherduck_token": "my_secret_token"}, - ) - - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") - - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" - - assert conn_str == "md:?motherduck_token=my_secret_token" - - def test_connection_string_with_database_and_token(self): - """Test md:database?token connection string.""" - config = ConnectionConfig( - name="test", - db_type="motherduck", - endpoint=TcpEndpoint(database="prod_db"), - options={"motherduck_token": "my_token"}, - ) - - database = config.get_option("database", "") or config.database or "" - token = config.get_option("motherduck_token", "") - - conn_str = f"md:{database}" if database else "md:" - if token: - conn_str += f"?motherduck_token={token}" - - assert conn_str == "md:prod_db?motherduck_token=my_token" +from sqlit.domains.connections.domain.config import ConnectionConfig, FileEndpoint def test_motherduck_provider_registered(): @@ -118,8 +19,9 @@ def test_motherduck_provider_metadata(): provider = get_provider("motherduck") assert provider.metadata.display_name == "MotherDuck" - assert provider.metadata.is_file_based is False + assert provider.metadata.is_file_based is True assert provider.metadata.supports_ssh is False + assert provider.metadata.requires_auth is True assert "md" in provider.metadata.url_schemes assert "motherduck" in provider.metadata.url_schemes @@ -129,3 +31,25 @@ def test_motherduck_database_type_enum(): from sqlit.domains.connections.domain.config import DatabaseType assert DatabaseType.MOTHERDUCK.value == "motherduck" + + +def test_motherduck_url_parsing(): + """Test MotherDuck URL parsing.""" + from sqlit.domains.connections.app.url_parser import parse_connection_url + + config = parse_connection_url("motherduck:///my_database?motherduck_token=abc123") + + assert config.db_type == "motherduck" + assert config.file_path == "/my_database" + assert config.extra_options.get("motherduck_token") == "abc123" + + +def test_motherduck_md_scheme_url_parsing(): + """Test MotherDuck md:// URL parsing.""" + from sqlit.domains.connections.app.url_parser import parse_connection_url + + config = parse_connection_url("md:///prod_db?motherduck_token=xyz789") + + assert config.db_type == "motherduck" + assert config.file_path == "/prod_db" + assert config.extra_options.get("motherduck_token") == "xyz789" From a4047e5e8ca65e503f97855c2b2f2cc9673a0493 Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:54:13 +0100 Subject: [PATCH 3/6] fix: use standard password field for MotherDuck token - Use standard password field (stored in keyring) instead of custom token field - Fix file-based vs tcp-based endpoint handling - Access token is now stored securely via credentials service --- .../providers/motherduck/adapter.py | 18 +++++------ .../providers/motherduck/provider.py | 4 +-- .../providers/motherduck/schema.py | 6 ++-- tests/unit/test_motherduck_adapter.py | 31 ++++++------------- 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py index 255530f..1478964 100644 --- a/sqlit/domains/connections/providers/motherduck/adapter.py +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -31,15 +31,15 @@ def connect(self, config: ConnectionConfig) -> Any: package_name=self.install_package, ) - # Get database from file_path - database = "" - if config.file_endpoint and config.file_endpoint.path: - database = config.file_endpoint.path.lstrip("/") - - # Get token from extra_options (URL) or options (UI) - token = config.extra_options.get("motherduck_token", "") - if not token: - token = config.get_option("motherduck_token", "") + # Get database from options or tcp_endpoint + database = config.get_option("database", "") + if not database and config.tcp_endpoint: + database = config.tcp_endpoint.database + + # Get token from tcp_endpoint.password (stored in keyring) + token = "" + if config.tcp_endpoint: + token = config.tcp_endpoint.password or "" if not database: raise ValueError("MotherDuck connections require a database name.") diff --git a/sqlit/domains/connections/providers/motherduck/provider.py b/sqlit/domains/connections/providers/motherduck/provider.py index 6776b57..7fd4c68 100644 --- a/sqlit/domains/connections/providers/motherduck/provider.py +++ b/sqlit/domains/connections/providers/motherduck/provider.py @@ -11,7 +11,7 @@ def _display_info(config) -> str: database = config.get_option("database", "") or config.database or "" if database: return f"md:{database}" - return "md: (default)" + return "MotherDuck" def _provider_factory(spec: ProviderSpec) -> DatabaseProvider: @@ -25,7 +25,7 @@ def _provider_factory(spec: ProviderSpec) -> DatabaseProvider: display_name="MotherDuck", schema_path=("sqlit.domains.connections.providers.motherduck.schema", "SCHEMA"), supports_ssh=False, - is_file_based=True, # Use file-based URL parsing (motherduck:///database) + is_file_based=False, has_advanced_auth=False, default_port="", requires_auth=True, diff --git a/sqlit/domains/connections/providers/motherduck/schema.py b/sqlit/domains/connections/providers/motherduck/schema.py index a53764a..6911166 100644 --- a/sqlit/domains/connections/providers/motherduck/schema.py +++ b/sqlit/domains/connections/providers/motherduck/schema.py @@ -11,19 +11,19 @@ display_name="MotherDuck", fields=( SchemaField( - name="file_path", + name="database", label="Database", placeholder="my_database", required=True, ), SchemaField( - name="motherduck_token", + name="password", label="Access Token", field_type=FieldType.PASSWORD, required=True, ), ), supports_ssh=False, - is_file_based=True, + is_file_based=False, # Not file-based, uses database + token requires_auth=True, ) diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py index 2539dfb..a510ee2 100644 --- a/tests/unit/test_motherduck_adapter.py +++ b/tests/unit/test_motherduck_adapter.py @@ -2,8 +2,6 @@ from __future__ import annotations -from sqlit.domains.connections.domain.config import ConnectionConfig, FileEndpoint - def test_motherduck_provider_registered(): """Test that MotherDuck provider is properly registered.""" @@ -19,7 +17,7 @@ def test_motherduck_provider_metadata(): provider = get_provider("motherduck") assert provider.metadata.display_name == "MotherDuck" - assert provider.metadata.is_file_based is True + assert provider.metadata.is_file_based is False assert provider.metadata.supports_ssh is False assert provider.metadata.requires_auth is True assert "md" in provider.metadata.url_schemes @@ -33,23 +31,14 @@ def test_motherduck_database_type_enum(): assert DatabaseType.MOTHERDUCK.value == "motherduck" -def test_motherduck_url_parsing(): - """Test MotherDuck URL parsing.""" - from sqlit.domains.connections.app.url_parser import parse_connection_url - - config = parse_connection_url("motherduck:///my_database?motherduck_token=abc123") - - assert config.db_type == "motherduck" - assert config.file_path == "/my_database" - assert config.extra_options.get("motherduck_token") == "abc123" - - -def test_motherduck_md_scheme_url_parsing(): - """Test MotherDuck md:// URL parsing.""" - from sqlit.domains.connections.app.url_parser import parse_connection_url +def test_motherduck_schema_uses_password_field(): + """Test MotherDuck schema uses standard password field for token.""" + from sqlit.domains.connections.providers.motherduck.schema import SCHEMA - config = parse_connection_url("md:///prod_db?motherduck_token=xyz789") + field_names = [f.name for f in SCHEMA.fields] + assert "database" in field_names + assert "password" in field_names # Uses standard password field for token - assert config.db_type == "motherduck" - assert config.file_path == "/prod_db" - assert config.extra_options.get("motherduck_token") == "xyz789" + # Password field should be labeled as "Access Token" + password_field = next(f for f in SCHEMA.fields if f.name == "password") + assert password_field.label == "Access Token" From 03c3f7ad7daadccc5bfb3dd92719df2e35cba77f Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:59:46 +0100 Subject: [PATCH 4/6] fix: use three-part names for MotherDuck queries MotherDuck requires database.schema.table format for queries. Override build_select_query to include the database prefix. --- .../providers/motherduck/adapter.py | 12 +++++++ tests/unit/test_motherduck_adapter.py | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py index 1478964..c51b10b 100644 --- a/sqlit/domains/connections/providers/motherduck/adapter.py +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -50,3 +50,15 @@ def connect(self, config: ConnectionConfig) -> Any: duckdb_any: Any = duckdb return duckdb_any.connect(conn_str) + + def build_select_query( + self, table: str, limit: int, database: str | None = None, schema: str | None = None + ) -> str: + """Build SELECT LIMIT query for MotherDuck. + + MotherDuck requires three-part names: database.schema.table + """ + schema = schema or "main" + if database: + return f'SELECT * FROM "{database}"."{schema}"."{table}" LIMIT {limit}' + return f'SELECT * FROM "{schema}"."{table}" LIMIT {limit}' diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py index a510ee2..3a450b4 100644 --- a/tests/unit/test_motherduck_adapter.py +++ b/tests/unit/test_motherduck_adapter.py @@ -42,3 +42,36 @@ def test_motherduck_schema_uses_password_field(): # Password field should be labeled as "Access Token" password_field = next(f for f in SCHEMA.fields if f.name == "password") assert password_field.label == "Access Token" + + +def test_motherduck_build_select_query_with_database(): + """Test MotherDuck uses three-part names (database.schema.table).""" + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + adapter = MotherDuckAdapter() + + # With database - should use three-part name + query = adapter.build_select_query("hacker_news", 100, database="sample_data", schema="hn") + assert query == 'SELECT * FROM "sample_data"."hn"."hacker_news" LIMIT 100' + + +def test_motherduck_build_select_query_without_database(): + """Test MotherDuck falls back to two-part names without database.""" + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + adapter = MotherDuckAdapter() + + # Without database - should use two-part name + query = adapter.build_select_query("my_table", 50, schema="main") + assert query == 'SELECT * FROM "main"."my_table" LIMIT 50' + + +def test_motherduck_build_select_query_default_schema(): + """Test MotherDuck defaults to 'main' schema.""" + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + adapter = MotherDuckAdapter() + + # No schema specified - should default to main + query = adapter.build_select_query("my_table", 25, database="my_db") + assert query == 'SELECT * FROM "my_db"."main"."my_table" LIMIT 25' From 72dc5c5f38f62000b153506a40a0a24358c31266 Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:03:01 +0100 Subject: [PATCH 5/6] fix: enable multiple databases support for MotherDuck MotherDuck supports multiple databases, so the explorer tree needs to pass the database name through to build correct queries. --- sqlit/domains/connections/providers/motherduck/adapter.py | 5 +++++ tests/unit/test_motherduck_adapter.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py index c51b10b..e43f9c3 100644 --- a/sqlit/domains/connections/providers/motherduck/adapter.py +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -22,6 +22,11 @@ def supports_process_worker(self) -> bool: """MotherDuck handles concurrency server-side.""" return True + @property + def supports_multiple_databases(self) -> bool: + """MotherDuck supports multiple databases.""" + return True + def connect(self, config: ConnectionConfig) -> Any: """Connect to MotherDuck cloud database.""" duckdb = self._import_driver_module( diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py index 3a450b4..d11a0cc 100644 --- a/tests/unit/test_motherduck_adapter.py +++ b/tests/unit/test_motherduck_adapter.py @@ -44,6 +44,14 @@ def test_motherduck_schema_uses_password_field(): assert password_field.label == "Access Token" +def test_motherduck_supports_multiple_databases(): + """Test MotherDuck reports support for multiple databases.""" + from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter + + adapter = MotherDuckAdapter() + assert adapter.supports_multiple_databases is True + + def test_motherduck_build_select_query_with_database(): """Test MotherDuck uses three-part names (database.schema.table).""" from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter From 12a70eed1d5a6555b47030489cc7b0035c827d59 Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:09:44 +0100 Subject: [PATCH 6/6] fix: show Databases folder for MotherDuck - Rename database field to default_database (stored in options) - This keeps tcp_endpoint.database empty so tree shows Databases folder - Add get_databases() to list all MotherDuck databases - Add get_tables/get_views() with database filtering --- .../providers/motherduck/adapter.py | 52 +++++++++++++++++-- .../providers/motherduck/schema.py | 6 +-- tests/unit/test_motherduck_adapter.py | 6 ++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/sqlit/domains/connections/providers/motherduck/adapter.py b/sqlit/domains/connections/providers/motherduck/adapter.py index e43f9c3..4f443c3 100644 --- a/sqlit/domains/connections/providers/motherduck/adapter.py +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any +from sqlit.domains.connections.providers.adapters.base import TableInfo from sqlit.domains.connections.providers.duckdb.adapter import DuckDBAdapter if TYPE_CHECKING: @@ -36,10 +37,8 @@ def connect(self, config: ConnectionConfig) -> Any: package_name=self.install_package, ) - # Get database from options or tcp_endpoint - database = config.get_option("database", "") - if not database and config.tcp_endpoint: - database = config.tcp_endpoint.database + # Get default database from options + database = config.get_option("default_database", "") # Get token from tcp_endpoint.password (stored in keyring) token = "" @@ -56,6 +55,51 @@ def connect(self, config: ConnectionConfig) -> Any: duckdb_any: Any = duckdb return duckdb_any.connect(conn_str) + def get_databases(self, conn: Any) -> list[str]: + """List all MotherDuck databases.""" + result = conn.execute("SELECT database_name FROM duckdb_databases() WHERE NOT internal") + return [row[0] for row in result.fetchall()] + + def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]: + """Get tables from a specific MotherDuck database.""" + if database: + result = conn.execute( + "SELECT table_schema, table_name FROM information_schema.tables " + "WHERE table_catalog = ? " + "AND table_type = 'BASE TABLE' " + "AND table_schema NOT IN ('pg_catalog', 'information_schema') " + "ORDER BY table_schema, table_name", + (database,), + ) + else: + result = conn.execute( + "SELECT table_schema, table_name FROM information_schema.tables " + "WHERE table_type = 'BASE TABLE' " + "AND table_schema NOT IN ('pg_catalog', 'information_schema') " + "ORDER BY table_schema, table_name" + ) + return [(row[0], row[1]) for row in result.fetchall()] + + def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]: + """Get views from a specific MotherDuck database.""" + if database: + result = conn.execute( + "SELECT table_schema, table_name FROM information_schema.tables " + "WHERE table_catalog = ? " + "AND table_type = 'VIEW' " + "AND table_schema NOT IN ('pg_catalog', 'information_schema') " + "ORDER BY table_schema, table_name", + (database,), + ) + else: + result = conn.execute( + "SELECT table_schema, table_name FROM information_schema.tables " + "WHERE table_type = 'VIEW' " + "AND table_schema NOT IN ('pg_catalog', 'information_schema') " + "ORDER BY table_schema, table_name" + ) + return [(row[0], row[1]) for row in result.fetchall()] + def build_select_query( self, table: str, limit: int, database: str | None = None, schema: str | None = None ) -> str: diff --git a/sqlit/domains/connections/providers/motherduck/schema.py b/sqlit/domains/connections/providers/motherduck/schema.py index 6911166..deee3e6 100644 --- a/sqlit/domains/connections/providers/motherduck/schema.py +++ b/sqlit/domains/connections/providers/motherduck/schema.py @@ -11,8 +11,8 @@ display_name="MotherDuck", fields=( SchemaField( - name="database", - label="Database", + name="default_database", + label="Default Database", placeholder="my_database", required=True, ), @@ -24,6 +24,6 @@ ), ), supports_ssh=False, - is_file_based=False, # Not file-based, uses database + token + is_file_based=False, requires_auth=True, ) diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py index d11a0cc..c7aa3d7 100644 --- a/tests/unit/test_motherduck_adapter.py +++ b/tests/unit/test_motherduck_adapter.py @@ -36,13 +36,17 @@ def test_motherduck_schema_uses_password_field(): from sqlit.domains.connections.providers.motherduck.schema import SCHEMA field_names = [f.name for f in SCHEMA.fields] - assert "database" in field_names + assert "default_database" in field_names assert "password" in field_names # Uses standard password field for token # Password field should be labeled as "Access Token" password_field = next(f for f in SCHEMA.fields if f.name == "password") assert password_field.label == "Access Token" + # Database field should be labeled as "Default Database" + db_field = next(f for f in SCHEMA.fields if f.name == "default_database") + assert db_field.label == "Default Database" + def test_motherduck_supports_multiple_databases(): """Test MotherDuck reports support for multiple databases."""