Skip to content

Commit 8cc72bd

Browse files
authored
Merge pull request #119 from Maxteabag/feature/motherduck-support
feat: add MotherDuck cloud DuckDB support
2 parents 852a287 + 12a70ee commit 8cc72bd

6 files changed

Lines changed: 272 additions & 0 deletions

File tree

sqlit/domains/connections/domain/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class DatabaseType(str, Enum):
2020
FLIGHT = "flight"
2121
HANA = "hana"
2222
MARIADB = "mariadb"
23+
MOTHERDUCK = "motherduck"
2324
MSSQL = "mssql"
2425
MYSQL = "mysql"
2526
ORACLE = "oracle"
@@ -52,6 +53,7 @@ class DatabaseType(str, Enum):
5253
DatabaseType.TRINO,
5354
DatabaseType.PRESTO,
5455
DatabaseType.DUCKDB,
56+
DatabaseType.MOTHERDUCK,
5557
DatabaseType.REDSHIFT,
5658
DatabaseType.CLICKHOUSE,
5759
DatabaseType.COCKROACHDB,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""MotherDuck provider package."""
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""MotherDuck adapter for cloud DuckDB."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
from sqlit.domains.connections.providers.adapters.base import TableInfo
8+
from sqlit.domains.connections.providers.duckdb.adapter import DuckDBAdapter
9+
10+
if TYPE_CHECKING:
11+
from sqlit.domains.connections.domain.config import ConnectionConfig
12+
13+
14+
class MotherDuckAdapter(DuckDBAdapter):
15+
"""Adapter for MotherDuck cloud DuckDB service."""
16+
17+
@property
18+
def name(self) -> str:
19+
return "MotherDuck"
20+
21+
@property
22+
def supports_process_worker(self) -> bool:
23+
"""MotherDuck handles concurrency server-side."""
24+
return True
25+
26+
@property
27+
def supports_multiple_databases(self) -> bool:
28+
"""MotherDuck supports multiple databases."""
29+
return True
30+
31+
def connect(self, config: ConnectionConfig) -> Any:
32+
"""Connect to MotherDuck cloud database."""
33+
duckdb = self._import_driver_module(
34+
"duckdb",
35+
driver_name=self.name,
36+
extra_name=self.install_extra,
37+
package_name=self.install_package,
38+
)
39+
40+
# Get default database from options
41+
database = config.get_option("default_database", "")
42+
43+
# Get token from tcp_endpoint.password (stored in keyring)
44+
token = ""
45+
if config.tcp_endpoint:
46+
token = config.tcp_endpoint.password or ""
47+
48+
if not database:
49+
raise ValueError("MotherDuck connections require a database name.")
50+
if not token:
51+
raise ValueError("MotherDuck connections require an access token.")
52+
53+
conn_str = f"md:{database}?motherduck_token={token}"
54+
55+
duckdb_any: Any = duckdb
56+
return duckdb_any.connect(conn_str)
57+
58+
def get_databases(self, conn: Any) -> list[str]:
59+
"""List all MotherDuck databases."""
60+
result = conn.execute("SELECT database_name FROM duckdb_databases() WHERE NOT internal")
61+
return [row[0] for row in result.fetchall()]
62+
63+
def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]:
64+
"""Get tables from a specific MotherDuck database."""
65+
if database:
66+
result = conn.execute(
67+
"SELECT table_schema, table_name FROM information_schema.tables "
68+
"WHERE table_catalog = ? "
69+
"AND table_type = 'BASE TABLE' "
70+
"AND table_schema NOT IN ('pg_catalog', 'information_schema') "
71+
"ORDER BY table_schema, table_name",
72+
(database,),
73+
)
74+
else:
75+
result = conn.execute(
76+
"SELECT table_schema, table_name FROM information_schema.tables "
77+
"WHERE table_type = 'BASE TABLE' "
78+
"AND table_schema NOT IN ('pg_catalog', 'information_schema') "
79+
"ORDER BY table_schema, table_name"
80+
)
81+
return [(row[0], row[1]) for row in result.fetchall()]
82+
83+
def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]:
84+
"""Get views from a specific MotherDuck database."""
85+
if database:
86+
result = conn.execute(
87+
"SELECT table_schema, table_name FROM information_schema.tables "
88+
"WHERE table_catalog = ? "
89+
"AND table_type = 'VIEW' "
90+
"AND table_schema NOT IN ('pg_catalog', 'information_schema') "
91+
"ORDER BY table_schema, table_name",
92+
(database,),
93+
)
94+
else:
95+
result = conn.execute(
96+
"SELECT table_schema, table_name FROM information_schema.tables "
97+
"WHERE table_type = 'VIEW' "
98+
"AND table_schema NOT IN ('pg_catalog', 'information_schema') "
99+
"ORDER BY table_schema, table_name"
100+
)
101+
return [(row[0], row[1]) for row in result.fetchall()]
102+
103+
def build_select_query(
104+
self, table: str, limit: int, database: str | None = None, schema: str | None = None
105+
) -> str:
106+
"""Build SELECT LIMIT query for MotherDuck.
107+
108+
MotherDuck requires three-part names: database.schema.table
109+
"""
110+
schema = schema or "main"
111+
if database:
112+
return f'SELECT * FROM "{database}"."{schema}"."{table}" LIMIT {limit}'
113+
return f'SELECT * FROM "{schema}"."{table}" LIMIT {limit}'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Provider registration for MotherDuck."""
2+
3+
from sqlit.domains.connections.providers.adapter_provider import build_adapter_provider
4+
from sqlit.domains.connections.providers.catalog import register_provider
5+
from sqlit.domains.connections.providers.model import DatabaseProvider, ProviderSpec
6+
from sqlit.domains.connections.providers.motherduck.schema import SCHEMA
7+
8+
9+
def _display_info(config) -> str:
10+
"""Display info for MotherDuck connections."""
11+
database = config.get_option("database", "") or config.database or ""
12+
if database:
13+
return f"md:{database}"
14+
return "MotherDuck"
15+
16+
17+
def _provider_factory(spec: ProviderSpec) -> DatabaseProvider:
18+
from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter
19+
20+
return build_adapter_provider(spec, SCHEMA, MotherDuckAdapter())
21+
22+
23+
SPEC = ProviderSpec(
24+
db_type="motherduck",
25+
display_name="MotherDuck",
26+
schema_path=("sqlit.domains.connections.providers.motherduck.schema", "SCHEMA"),
27+
supports_ssh=False,
28+
is_file_based=False,
29+
has_advanced_auth=False,
30+
default_port="",
31+
requires_auth=True,
32+
badge_label="MotherDuck",
33+
url_schemes=("motherduck", "md"),
34+
provider_factory=_provider_factory,
35+
display_info=_display_info,
36+
)
37+
38+
register_provider(SPEC)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Connection schema for MotherDuck."""
2+
3+
from sqlit.domains.connections.providers.schema_helpers import (
4+
ConnectionSchema,
5+
FieldType,
6+
SchemaField,
7+
)
8+
9+
SCHEMA = ConnectionSchema(
10+
db_type="motherduck",
11+
display_name="MotherDuck",
12+
fields=(
13+
SchemaField(
14+
name="default_database",
15+
label="Default Database",
16+
placeholder="my_database",
17+
required=True,
18+
),
19+
SchemaField(
20+
name="password",
21+
label="Access Token",
22+
field_type=FieldType.PASSWORD,
23+
required=True,
24+
),
25+
),
26+
supports_ssh=False,
27+
is_file_based=False,
28+
requires_auth=True,
29+
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Unit tests for MotherDuck adapter."""
2+
3+
from __future__ import annotations
4+
5+
6+
def test_motherduck_provider_registered():
7+
"""Test that MotherDuck provider is properly registered."""
8+
from sqlit.domains.connections.providers.catalog import get_supported_db_types
9+
10+
db_types = get_supported_db_types()
11+
assert "motherduck" in db_types
12+
13+
14+
def test_motherduck_provider_metadata():
15+
"""Test MotherDuck provider metadata."""
16+
from sqlit.domains.connections.providers.catalog import get_provider
17+
18+
provider = get_provider("motherduck")
19+
assert provider.metadata.display_name == "MotherDuck"
20+
assert provider.metadata.is_file_based is False
21+
assert provider.metadata.supports_ssh is False
22+
assert provider.metadata.requires_auth is True
23+
assert "md" in provider.metadata.url_schemes
24+
assert "motherduck" in provider.metadata.url_schemes
25+
26+
27+
def test_motherduck_database_type_enum():
28+
"""Test MotherDuck is in DatabaseType enum."""
29+
from sqlit.domains.connections.domain.config import DatabaseType
30+
31+
assert DatabaseType.MOTHERDUCK.value == "motherduck"
32+
33+
34+
def test_motherduck_schema_uses_password_field():
35+
"""Test MotherDuck schema uses standard password field for token."""
36+
from sqlit.domains.connections.providers.motherduck.schema import SCHEMA
37+
38+
field_names = [f.name for f in SCHEMA.fields]
39+
assert "default_database" in field_names
40+
assert "password" in field_names # Uses standard password field for token
41+
42+
# Password field should be labeled as "Access Token"
43+
password_field = next(f for f in SCHEMA.fields if f.name == "password")
44+
assert password_field.label == "Access Token"
45+
46+
# Database field should be labeled as "Default Database"
47+
db_field = next(f for f in SCHEMA.fields if f.name == "default_database")
48+
assert db_field.label == "Default Database"
49+
50+
51+
def test_motherduck_supports_multiple_databases():
52+
"""Test MotherDuck reports support for multiple databases."""
53+
from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter
54+
55+
adapter = MotherDuckAdapter()
56+
assert adapter.supports_multiple_databases is True
57+
58+
59+
def test_motherduck_build_select_query_with_database():
60+
"""Test MotherDuck uses three-part names (database.schema.table)."""
61+
from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter
62+
63+
adapter = MotherDuckAdapter()
64+
65+
# With database - should use three-part name
66+
query = adapter.build_select_query("hacker_news", 100, database="sample_data", schema="hn")
67+
assert query == 'SELECT * FROM "sample_data"."hn"."hacker_news" LIMIT 100'
68+
69+
70+
def test_motherduck_build_select_query_without_database():
71+
"""Test MotherDuck falls back to two-part names without database."""
72+
from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter
73+
74+
adapter = MotherDuckAdapter()
75+
76+
# Without database - should use two-part name
77+
query = adapter.build_select_query("my_table", 50, schema="main")
78+
assert query == 'SELECT * FROM "main"."my_table" LIMIT 50'
79+
80+
81+
def test_motherduck_build_select_query_default_schema():
82+
"""Test MotherDuck defaults to 'main' schema."""
83+
from sqlit.domains.connections.providers.motherduck.adapter import MotherDuckAdapter
84+
85+
adapter = MotherDuckAdapter()
86+
87+
# No schema specified - should default to main
88+
query = adapter.build_select_query("my_table", 25, database="my_db")
89+
assert query == 'SELECT * FROM "my_db"."main"."my_table" LIMIT 25'

0 commit comments

Comments
 (0)