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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ snowflake = [
postgres = ["psycopg2-binary==2.9.9", "ibis-framework[postgres]>=11.0.0"]
bigquery = ["ibis-framework[bigquery]>=11.0.0"]
pyspark = ["setuptools", "ibis-framework[pyspark]>=11.0.0"]
clickhouse = ["ibis-framework[clickhouse]>=11.0.0"]
trino = ["ibis-framework[trino]>=11.0.0"]


Expand Down
31 changes: 31 additions & 0 deletions src/mountainash_data/backends/ibis/dialects/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,31 @@ def _build_trino_connection(**config: t.Any) -> t.Any:
return ibis.connect(conn_str, **extra)


def _build_clickhouse_connection(**config: t.Any) -> t.Any:
"""Build a ClickHouse ibis connection.

Uses ibis.clickhouse.connect() with kwargs: host, port, user, password,
database, secure.
"""
import ibis

host = config.get("host", "localhost")
port = config.get("port", 9000)
user = config.get("user", config.get("username", "default"))
password = config.get("password", "")
database = config.get("database", "default")
secure = config.get("secure", False)

extra = {k: v for k, v in config.items()
if k not in ("host", "port", "user", "username", "password",
"database", "secure", "connection_string")}

return ibis.clickhouse.connect(
host=host, port=port, user=user, password=password,
database=database, secure=secure, **extra,
)


def _build_pyspark_connection(**config: t.Any) -> t.Any:
"""Build a PySpark ibis connection.

Expand Down Expand Up @@ -487,6 +512,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any:
connection_string_scheme="trino://",
connection_builder=_build_trino_connection,
),
"clickhouse": DialectSpec(
ibis_backend_name="clickhouse",
connection_mode=_KWARGS,
connection_string_scheme="clickhouse://",
connection_builder=_build_clickhouse_connection,
),
"pyspark": DialectSpec(
ibis_backend_name="pyspark",
connection_mode=_CONNECTION_STRING,
Expand Down
3 changes: 3 additions & 0 deletions src/mountainash_data/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class CONST_DB_PROVIDER_TYPE(Enum):
TRINO = auto()
PYICEBERG_REST = auto()
ORACLE = auto()
CLICKHOUSE = auto()
PYSPARK = auto()


Expand Down Expand Up @@ -108,6 +109,7 @@ class CONST_DB_BACKEND(StrEnum):
REDSHIFT = "REDSHIFT"
MSSQL = "MSSQL"
MYSQL = "MYSQL"
CLICKHOUSE = "CLICKHOUSE"
MOTHERDUCK = "MOTHERDUCK"
PYICEBERG = "PYICEBERG"
# POLARS = "POLARS"
Expand Down Expand Up @@ -142,6 +144,7 @@ class CONST_DB_BACKEND_IBIS_PREFIX(StrEnum):
ORACLE = "oracle:"
REDSHIFT = "redshift:"
MSSQL = "mssql:"
CLICKHOUSE = "clickhouse:"
MYSQL = "mysql:"

class CONST_DB_BACKEND_CAPABILITIES(Enum):
Expand Down
4 changes: 3 additions & 1 deletion src/mountainash_data/core/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``).
from .duckdb import DuckDBAuthSettings
from .motherduck import MotherDuckAuthSettings
from .postgresql import PostgreSQLAuthSettings
from .clickhouse import ClickHouseAuthSettings
from .mysql import MySQLAuthSettings
from .mssql import MSSQLAuthSettings
from .snowflake import SnowflakeAuthSettings
Expand All @@ -58,7 +59,8 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``).
"AzureADAuth", "KerberosAuth", "CertificateAuth",
# backends
"SQLiteAuthSettings", "DuckDBAuthSettings", "MotherDuckAuthSettings",
"PostgreSQLAuthSettings", "MySQLAuthSettings", "MSSQLAuthSettings",
"PostgreSQLAuthSettings", "ClickHouseAuthSettings",
"MySQLAuthSettings", "MSSQLAuthSettings",
"SnowflakeAuthSettings", "BigQueryAuthSettings", "RedshiftAuthSettings",
"PySparkAuthSettings", "TrinoAuthSettings", "PyIcebergRestAuthSettings",
]
49 changes: 49 additions & 0 deletions src/mountainash_data/core/settings/clickhouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""ClickHouse backend settings.

Driver: https://clickhouse.com/docs/en/integrations/python
Ibis: ``ibis.clickhouse.connect(host='localhost', port=9000, database='default',
user='default', password='', **kwargs)``
"""

from __future__ import annotations

import typing as t

from ..constants import CONST_DB_PROVIDER_TYPE
from mountainash_settings.auth import NoAuth, PasswordAuth
from .descriptor import BackendDescriptor, ParameterSpec
from .profile import ConnectionProfile
from .registry import register


CLICKHOUSE_DESCRIPTOR = BackendDescriptor(
name="clickhouse",
provider_type=CONST_DB_PROVIDER_TYPE.CLICKHOUSE,
default_port=9000,
connection_string_scheme="clickhouse://",
ibis_dialect="clickhouse",
auth_modes=[PasswordAuth, NoAuth],
parameters=[
ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"),
ParameterSpec(name="PORT", type=int, tier="core", default=9000,
driver_key="port"),
ParameterSpec(name="DATABASE", type=t.Optional[str], tier="core",
default=None, driver_key="database"),
ParameterSpec(name="SECURE", type=bool, tier="core",
default=False, driver_key="secure"),
ParameterSpec(name="CONNECT_TIMEOUT", type=t.Optional[int],
tier="advanced", default=None,
driver_key="connect_timeout"),
ParameterSpec(name="SEND_RECEIVE_TIMEOUT", type=t.Optional[int],
tier="advanced", default=None,
driver_key="send_receive_timeout"),
ParameterSpec(name="SYNC_REQUEST_TIMEOUT", type=t.Optional[int],
tier="advanced", default=None,
driver_key="sync_request_timeout"),
],
)


@register(CLICKHOUSE_DESCRIPTOR)
class ClickHouseAuthSettings(ConnectionProfile):
__descriptor__ = CLICKHOUSE_DESCRIPTOR
3 changes: 2 additions & 1 deletion tests/test_unit/backends/ibis/test_dialect_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ def fake_index_sql(table_name, index_name):
assert spec.get_index_exists_sql("users", "idx_users_id") == "SELECT 1 FROM users"


def test_registry_contains_all_12_backends():
def test_registry_contains_all_13_backends():
expected = {
"sqlite", "duckdb", "motherduck", "postgres", "mysql", "mssql",
"oracle", "snowflake", "bigquery", "redshift", "trino", "pyspark",
"clickhouse",
}
assert set(DIALECTS.keys()) == expected

Expand Down
57 changes: 57 additions & 0 deletions tests/test_unit/core/settings/backends/test_clickhouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# tests/test_unit/core/settings/backends/test_clickhouse.py
from __future__ import annotations

import pytest
from pydantic import SecretStr

from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE
from mountainash_data.core.settings.auth import NoAuth, PasswordAuth
from mountainash_data.core.settings.clickhouse import ClickHouseAuthSettings


@pytest.mark.unit
class TestClickHouseAuthSettings:
def _minimal(self, **extra):
return ClickHouseAuthSettings(
HOST="ch.example.com",
auth=PasswordAuth(username="demo", password=SecretStr("s3cret")),
**extra,
)

def test_provider_type_is_clickhouse(self):
s = self._minimal()
assert s.provider_type == CONST_DB_PROVIDER_TYPE.CLICKHOUSE

def test_default_port(self):
s = self._minimal()
assert s.PORT == 9000

def test_custom_port(self):
s = self._minimal(PORT=443)
assert s.PORT == 443

def test_secure_default_false(self):
s = self._minimal()
assert s.SECURE is False

def test_secure_true(self):
s = self._minimal(SECURE=True)
assert s.SECURE is True

def test_no_auth(self):
s = ClickHouseAuthSettings(HOST="ch.example.com", auth=NoAuth())
assert s.HOST == "ch.example.com"

def test_to_driver_kwargs_plumbs_core_fields(self):
s = self._minimal(PORT=443, DATABASE="pypi", SECURE=True)
kwargs = s.to_driver_kwargs()
assert kwargs["host"] == "ch.example.com"
assert kwargs["port"] == 443
assert kwargs["database"] == "pypi"
assert kwargs["secure"] is True
assert kwargs["user"] == "demo"
assert kwargs["password"] == "s3cret"

def test_ibis_dialect(self):
s = self._minimal()
assert s.backend == "clickhouse"
Loading