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
2 changes: 2 additions & 0 deletions sqlit/domains/connections/domain/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class DatabaseType(str, Enum):
FLIGHT = "flight"
HANA = "hana"
MARIADB = "mariadb"
MOTHERDUCK = "motherduck"
MSSQL = "mssql"
MYSQL = "mysql"
ORACLE = "oracle"
Expand Down Expand Up @@ -52,6 +53,7 @@ class DatabaseType(str, Enum):
DatabaseType.TRINO,
DatabaseType.PRESTO,
DatabaseType.DUCKDB,
DatabaseType.MOTHERDUCK,
DatabaseType.REDSHIFT,
DatabaseType.CLICKHOUSE,
DatabaseType.COCKROACHDB,
Expand Down
1 change: 1 addition & 0 deletions sqlit/domains/connections/providers/motherduck/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""MotherDuck provider package."""
113 changes: 113 additions & 0 deletions sqlit/domains/connections/providers/motherduck/adapter.py
Original file line number Diff line number Diff line change
@@ -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}'
38 changes: 38 additions & 0 deletions sqlit/domains/connections/providers/motherduck/provider.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions sqlit/domains/connections/providers/motherduck/schema.py
Original file line number Diff line number Diff line change
@@ -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,
)
89 changes: 89 additions & 0 deletions tests/unit/test_motherduck_adapter.py
Original file line number Diff line number Diff line change
@@ -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'
Loading