From b7663f946b1fc44e1323f585aaed715dbcc6cca0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Tue, 5 May 2026 12:56:27 +1000 Subject: [PATCH] feat(clickhouse): add ClickHouse dialect to IbisBackend Closes #108. Registers ClickHouse as the 13th supported dialect, following the existing data-driven DialectSpec pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 + .../backends/ibis/dialects/_registry.py | 31 ++++++++++ src/mountainash_data/core/constants.py | 3 + .../core/settings/__init__.py | 4 +- .../core/settings/clickhouse.py | 49 ++++++++++++++++ .../backends/ibis/test_dialect_spec.py | 3 +- .../core/settings/backends/test_clickhouse.py | 57 +++++++++++++++++++ 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/mountainash_data/core/settings/clickhouse.py create mode 100644 tests/test_unit/core/settings/backends/test_clickhouse.py diff --git a/pyproject.toml b/pyproject.toml index 4211a31..161120d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index 085d43a..cdf9efe 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -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. @@ -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, diff --git a/src/mountainash_data/core/constants.py b/src/mountainash_data/core/constants.py index 1a614ca..5b7aefd 100644 --- a/src/mountainash_data/core/constants.py +++ b/src/mountainash_data/core/constants.py @@ -23,6 +23,7 @@ class CONST_DB_PROVIDER_TYPE(Enum): TRINO = auto() PYICEBERG_REST = auto() ORACLE = auto() + CLICKHOUSE = auto() PYSPARK = auto() @@ -108,6 +109,7 @@ class CONST_DB_BACKEND(StrEnum): REDSHIFT = "REDSHIFT" MSSQL = "MSSQL" MYSQL = "MYSQL" + CLICKHOUSE = "CLICKHOUSE" MOTHERDUCK = "MOTHERDUCK" PYICEBERG = "PYICEBERG" # POLARS = "POLARS" @@ -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): diff --git a/src/mountainash_data/core/settings/__init__.py b/src/mountainash_data/core/settings/__init__.py index def26ab..59fa64a 100644 --- a/src/mountainash_data/core/settings/__init__.py +++ b/src/mountainash_data/core/settings/__init__.py @@ -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 @@ -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", ] diff --git a/src/mountainash_data/core/settings/clickhouse.py b/src/mountainash_data/core/settings/clickhouse.py new file mode 100644 index 0000000..0200797 --- /dev/null +++ b/src/mountainash_data/core/settings/clickhouse.py @@ -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 diff --git a/tests/test_unit/backends/ibis/test_dialect_spec.py b/tests/test_unit/backends/ibis/test_dialect_spec.py index 9a42522..573d821 100644 --- a/tests/test_unit/backends/ibis/test_dialect_spec.py +++ b/tests/test_unit/backends/ibis/test_dialect_spec.py @@ -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 diff --git a/tests/test_unit/core/settings/backends/test_clickhouse.py b/tests/test_unit/core/settings/backends/test_clickhouse.py new file mode 100644 index 0000000..fd1cba2 --- /dev/null +++ b/tests/test_unit/core/settings/backends/test_clickhouse.py @@ -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"