From 894a53b6a02b7fc12eeb6e2f23ac8f038d49f063 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Mon, 11 May 2026 10:24:55 +1000 Subject: [PATCH] feat(singlestoredb): add SingleStoreDB dialect to IbisBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers SingleStoreDB as the 15th supported dialect. MySQL-compatible connection model with added driver protocol selector (mysql/http/https) and local_infile parameter. No adapter needed — all params map directly via driver_key. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 5 +- .../backends/ibis/dialects/_registry.py | 46 ++++++++++++ src/mountainash_data/core/constants.py | 3 + .../core/settings/__init__.py | 4 +- .../core/settings/singlestoredb.py | 51 +++++++++++++ .../backends/ibis/test_dialect_spec.py | 4 +- .../settings/backends/test_singlestoredb.py | 74 +++++++++++++++++++ 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/mountainash_data/core/settings/singlestoredb.py create mode 100644 tests/test_unit/core/settings/backends/test_singlestoredb.py diff --git a/pyproject.toml b/pyproject.toml index 4e98505..5365ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ bigquery = ["ibis-framework[bigquery]>=11.0.0"] pyspark = ["setuptools", "ibis-framework[pyspark]>=11.0.0"] clickhouse = ["ibis-framework[clickhouse]>=11.0.0"] databricks = ["databricks-sql-connector>=4", "ibis-framework[databricks]>=11.0.0"] +singlestoredb = ["singlestoredb>=1.0", "ibis-framework[singlestoredb]>=11.0.0"] trino = ["ibis-framework[trino]>=11.0.0"] @@ -94,7 +95,3 @@ tests = ["tests", "*/mountainash-data/tests"] [tool.coverage.report] exclude_lines = ["no cov", "if __name__ == .__main__:", "if TYPE_CHECKING:"] - -[tool.pyright] -venvPath = "/home/nathanielramm/git/mountainash-ide/mountainash-dev-local/.venv" -venv = ".venv" diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index 229297f..ca008e0 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -430,6 +430,46 @@ def _build_databricks_connection(**config: t.Any) -> t.Any: return ibis.databricks.connect(**kwargs) +def _build_singlestoredb_connection(**config: t.Any) -> t.Any: + """Build a SingleStoreDB ibis connection. + + Uses ibis.singlestoredb.connect() with kwargs: host, port, user, password, + database, driver, autocommit, local_infile. + """ + import ibis + + host = config.get("host", "localhost") + port = config.get("port", 3306) + user = config.get("user", config.get("username", None)) + password = config.get("password", None) + database = config.get("database", None) + driver = config.get("driver", None) + autocommit = config.get("autocommit", True) + local_infile = config.get("local_infile", True) + + known = {"host", "port", "user", "username", "password", "database", + "driver", "autocommit", "local_infile", "connection_string"} + extra = {k: v for k, v in config.items() if k not in known} + + kwargs: dict[str, t.Any] = { + "host": host, + "port": port, + "autocommit": autocommit, + "local_infile": local_infile, + } + if user is not None: + kwargs["user"] = user + if password is not None: + kwargs["password"] = password + if database is not None: + kwargs["database"] = database + if driver is not None: + kwargs["driver"] = driver + + kwargs.update(extra) + return ibis.singlestoredb.connect(**kwargs) + + def _build_pyspark_connection(**config: t.Any) -> t.Any: """Build a PySpark ibis connection. @@ -567,6 +607,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="", connection_builder=_build_databricks_connection, ), + "singlestoredb": DialectSpec( + ibis_backend_name="singlestoredb", + connection_mode=_KWARGS, + connection_string_scheme="singlestoredb://", + connection_builder=_build_singlestoredb_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 0134209..8676311 100644 --- a/src/mountainash_data/core/constants.py +++ b/src/mountainash_data/core/constants.py @@ -25,6 +25,7 @@ class CONST_DB_PROVIDER_TYPE(Enum): ORACLE = auto() CLICKHOUSE = auto() DATABRICKS = auto() + SINGLESTOREDB = auto() PYSPARK = auto() @@ -112,6 +113,7 @@ class CONST_DB_BACKEND(StrEnum): MYSQL = "MYSQL" CLICKHOUSE = "CLICKHOUSE" MOTHERDUCK = "MOTHERDUCK" + SINGLESTOREDB = "SINGLESTOREDB" PYICEBERG = "PYICEBERG" # POLARS = "POLARS" # PANDAS = "PANDAS" @@ -147,6 +149,7 @@ class CONST_DB_BACKEND_IBIS_PREFIX(StrEnum): MSSQL = "mssql:" CLICKHOUSE = "clickhouse:" MYSQL = "mysql:" + SINGLESTOREDB = "singlestoredb:" 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 c4b7bcf..32c3b72 100644 --- a/src/mountainash_data/core/settings/__init__.py +++ b/src/mountainash_data/core/settings/__init__.py @@ -41,6 +41,7 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``). from .clickhouse import ClickHouseAuthSettings from .databricks import DatabricksAuthSettings from .mysql import MySQLAuthSettings +from .singlestoredb import SingleStoreDBAuthSettings from .mssql import MSSQLAuthSettings from .snowflake import SnowflakeAuthSettings from .bigquery import BigQueryAuthSettings @@ -61,7 +62,8 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``). # backends "SQLiteAuthSettings", "DuckDBAuthSettings", "MotherDuckAuthSettings", "PostgreSQLAuthSettings", "ClickHouseAuthSettings", - "DatabricksAuthSettings", "MySQLAuthSettings", "MSSQLAuthSettings", + "DatabricksAuthSettings", "MySQLAuthSettings", "SingleStoreDBAuthSettings", + "MSSQLAuthSettings", "SnowflakeAuthSettings", "BigQueryAuthSettings", "RedshiftAuthSettings", "PySparkAuthSettings", "TrinoAuthSettings", "PyIcebergRestAuthSettings", ] diff --git a/src/mountainash_data/core/settings/singlestoredb.py b/src/mountainash_data/core/settings/singlestoredb.py new file mode 100644 index 0000000..e85d754 --- /dev/null +++ b/src/mountainash_data/core/settings/singlestoredb.py @@ -0,0 +1,51 @@ +"""SingleStoreDB backend settings. + +Driver: https://singlestoredb-python.labs.singlestore.com/ +Ibis: ``ibis.singlestoredb.connect(host, user, password, port, database, + driver, autocommit, local_infile, **kwargs)`` +""" + +from __future__ import annotations + +import typing as t +from enum import StrEnum + +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 + + +class SingleStoreDriver(StrEnum): + MYSQL = "mysql" + HTTP = "http" + HTTPS = "https" + + +SINGLESTOREDB_DESCRIPTOR = BackendDescriptor( + name="singlestoredb", + provider_type=CONST_DB_PROVIDER_TYPE.SINGLESTOREDB, + default_port=3306, + connection_string_scheme="singlestoredb://", + ibis_dialect="singlestoredb", + auth_modes=[PasswordAuth, NoAuth], + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), + ParameterSpec(name="PORT", type=int, tier="core", default=3306, + driver_key="port"), + ParameterSpec(name="DATABASE", type=t.Optional[str], tier="core", + default=None, driver_key="database"), + ParameterSpec(name="DRIVER", type=t.Optional[SingleStoreDriver], + tier="core", default=None, driver_key="driver"), + ParameterSpec(name="AUTOCOMMIT", type=bool, tier="core", + default=True, driver_key="autocommit"), + ParameterSpec(name="LOCAL_INFILE", type=bool, tier="advanced", + default=True, driver_key="local_infile"), + ], +) + + +@register(SINGLESTOREDB_DESCRIPTOR) +class SingleStoreDBAuthSettings(ConnectionProfile): + __descriptor__ = SINGLESTOREDB_DESCRIPTOR diff --git a/tests/test_unit/backends/ibis/test_dialect_spec.py b/tests/test_unit/backends/ibis/test_dialect_spec.py index 7040c60..25a0f12 100644 --- a/tests/test_unit/backends/ibis/test_dialect_spec.py +++ b/tests/test_unit/backends/ibis/test_dialect_spec.py @@ -33,11 +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_14_backends(): +def test_registry_contains_all_15_backends(): expected = { "sqlite", "duckdb", "motherduck", "postgres", "mysql", "mssql", "oracle", "snowflake", "bigquery", "redshift", "trino", "pyspark", - "clickhouse", "databricks", + "clickhouse", "databricks", "singlestoredb", } assert set(DIALECTS.keys()) == expected diff --git a/tests/test_unit/core/settings/backends/test_singlestoredb.py b/tests/test_unit/core/settings/backends/test_singlestoredb.py new file mode 100644 index 0000000..6780383 --- /dev/null +++ b/tests/test_unit/core/settings/backends/test_singlestoredb.py @@ -0,0 +1,74 @@ +# tests/test_unit/core/settings/backends/test_singlestoredb.py +from __future__ import annotations + +import pytest +from pydantic import SecretStr, ValidationError + +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE +from mountainash_data.core.settings.auth import NoAuth, PasswordAuth +from mountainash_data.core.settings.singlestoredb import ( + SingleStoreDBAuthSettings, + SingleStoreDriver, +) + + +@pytest.mark.unit +class TestSingleStoreDBAuthSettings: + def _minimal(self, **extra): + return SingleStoreDBAuthSettings( + HOST="svc-123.svc.singlestore.com", + auth=PasswordAuth(username="admin", password=SecretStr("s3cret")), + **extra, + ) + + def test_provider_type_is_singlestoredb(self): + s = self._minimal() + assert s.provider_type == CONST_DB_PROVIDER_TYPE.SINGLESTOREDB + + def test_default_port(self): + s = self._minimal() + assert s.PORT == 3306 + + def test_custom_port(self): + s = self._minimal(PORT=3307) + assert s.PORT == 3307 + + def test_driver_enum_enforced(self): + with pytest.raises(ValidationError): + self._minimal(DRIVER="nonsense") + + def test_driver_mysql(self): + s = self._minimal(DRIVER=SingleStoreDriver.MYSQL) + assert s.DRIVER == SingleStoreDriver.MYSQL + + def test_driver_https(self): + s = self._minimal(DRIVER=SingleStoreDriver.HTTPS) + kwargs = s.to_driver_kwargs() + assert kwargs["driver"] == "https" + + def test_autocommit_default_true(self): + s = self._minimal() + assert s.AUTOCOMMIT is True + + def test_local_infile_default_true(self): + s = self._minimal() + assert s.LOCAL_INFILE is True + + def test_no_auth(self): + s = SingleStoreDBAuthSettings( + HOST="svc-123.svc.singlestore.com", auth=NoAuth(), + ) + assert s.HOST == "svc-123.svc.singlestore.com" + + def test_to_driver_kwargs_plumbs_core_fields(self): + s = self._minimal(PORT=3307, DATABASE="mydb") + kwargs = s.to_driver_kwargs() + assert kwargs["host"] == "svc-123.svc.singlestore.com" + assert kwargs["port"] == 3307 + assert kwargs["database"] == "mydb" + assert kwargs["user"] == "admin" + assert kwargs["password"] == "s3cret" + + def test_ibis_dialect(self): + s = self._minimal() + assert s.backend == "singlestoredb"