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..4f443c3 --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/adapter.py @@ -0,0 +1,113 @@ +"""MotherDuck adapter for cloud DuckDB.""" + +from __future__ import annotations + +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: + 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 + + @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( + "duckdb", + driver_name=self.name, + extra_name=self.install_extra, + package_name=self.install_package, + ) + + # Get default database from options + database = config.get_option("default_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.") + if not token: + raise ValueError("MotherDuck connections require an access token.") + + conn_str = f"md:{database}?motherduck_token={token}" + + 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: + """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/sqlit/domains/connections/providers/motherduck/provider.py b/sqlit/domains/connections/providers/motherduck/provider.py new file mode 100644 index 0000000..7fd4c68 --- /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 "MotherDuck" + + +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=True, + 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..deee3e6 --- /dev/null +++ b/sqlit/domains/connections/providers/motherduck/schema.py @@ -0,0 +1,29 @@ +"""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="default_database", + label="Default Database", + placeholder="my_database", + required=True, + ), + SchemaField( + name="password", + label="Access Token", + field_type=FieldType.PASSWORD, + required=True, + ), + ), + supports_ssh=False, + is_file_based=False, + requires_auth=True, +) diff --git a/tests/unit/test_motherduck_adapter.py b/tests/unit/test_motherduck_adapter.py new file mode 100644 index 0000000..c7aa3d7 --- /dev/null +++ b/tests/unit/test_motherduck_adapter.py @@ -0,0 +1,89 @@ +"""Unit tests for MotherDuck adapter.""" + +from __future__ import annotations + + +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 provider.metadata.requires_auth is True + 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" + + +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 + + field_names = [f.name for f in SCHEMA.fields] + 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.""" + 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 + + 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'