This is an automated email from the ASF dual-hosted git repository.

cgivre pushed a commit to branch feat/drill-mcp-server
in repository https://gitbox.apache.org/repos/asf/drill-mcp.git

commit 1058cfeb975470ff9ffcc028287ef99bef1ee077
Author: cgivre <[email protected]>
AuthorDate: Wed Aug 12 00:54:07 2026 -0400

    feat: optional JDBC backend
    
    Add JdbcClient, delegating query building/row-mapping for metadata to
    the fetch_* functions extracted from RestClient. Management endpoints
    (storage_plugins, cluster_status, profiles, profile, cancel_query) are
    REST-only and deliberately absent. jaydebeapi is imported lazily and
    mocked in tests, so the suite never starts a JVM.
---
 drill_mcp/client_jdbc.py  | 140 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_client_jdbc.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 280 insertions(+)

diff --git a/drill_mcp/client_jdbc.py b/drill_mcp/client_jdbc.py
new file mode 100644
index 0000000..eef2d15
--- /dev/null
+++ b/drill_mcp/client_jdbc.py
@@ -0,0 +1,140 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+# either express or implied.  See the License for the specific
+# language governing permissions and limitations under the
+# License.
+#
+
+"""Drill JDBC backend.
+
+Optional, installed via `pip install drill-mcp[jdbc]`. It exists mainly because
+Kerberos is materially less painful through the Drill JDBC driver than through
+Python SPNEGO. Query and metadata only -- management endpoints are REST-only,
+so `JdbcClient` deliberately does not implement `storage_plugins`,
+`cluster_status`, `profiles`, `profile`, or `cancel_query`.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlparse
+
+from .client_rest import (
+    DrillError,
+    QueryResult,
+    fetch_columns,
+    fetch_plugin_type,
+    fetch_schemas,
+    fetch_tables,
+)
+from .config import Config
+
+DRIVER_CLASS = "org.apache.drill.jdbc.Driver"
+
+
+class JdbcClient:
+    def __init__(self, config: Config) -> None:
+        self._config = config
+        self._connection: Any = None
+
+    # -- connection --------------------------------------------------------
+
+    def _jdbc_url(self) -> str:
+        # Credentials are never embedded in the URL -- they are passed to
+        # jaydebeapi.connect() as a separate argument (see _connect below) --
+        # so this string, and anything derived from it in an error message,
+        # cannot leak the password.
+        host = urlparse(self._config.url)
+        netloc = host.netloc or host.path
+        url = f"jdbc:drill:drillbit={netloc}"
+        if self._config.auth == "kerberos":
+            url += ";auth=kerberos"
+        return url
+
+    def _connect(self) -> Any:
+        if self._connection is not None:
+            return self._connection
+        try:
+            import jaydebeapi
+        except ImportError as exc:
+            raise DrillError(
+                "the JDBC backend requires the jdbc extra: pip install 
drill-mcp[jdbc]"
+            ) from exc
+        if jaydebeapi is None:
+            raise DrillError(
+                "the JDBC backend requires the jdbc extra: pip install 
drill-mcp[jdbc]"
+            )
+        credentials = (
+            [self._config.user, self._config.password]
+            if self._config.auth == "basic"
+            else []
+        )
+        try:
+            self._connection = jaydebeapi.connect(
+                DRIVER_CLASS,
+                self._jdbc_url(),
+                credentials,
+                jars=[self._config.jdbc_driver_path],
+            )
+        except Exception as exc:
+            # `exc` is whatever the driver reports; it is never supplemented
+            # here with the URL or credentials, so this message can only leak
+            # a credential if the driver itself already put one in `exc`.
+            raise DrillError(f"could not connect to Drill over JDBC: {exc}") 
from exc
+        return self._connection
+
+    def close(self) -> None:
+        if self._connection is not None:
+            self._connection.close()
+            self._connection = None
+
+    # -- queries -------------------------------------------------------------
+
+    def query(self, sql: str, max_rows: int) -> QueryResult:
+        connection = self._connect()
+        try:
+            cursor = connection.cursor()
+            cursor.execute(sql)
+            rows = cursor.fetchmany(max_rows)
+            columns = [description[0] for description in cursor.description or 
[]]
+        except DrillError:
+            raise
+        except Exception as exc:
+            raise DrillError(str(exc)) from exc
+        return QueryResult(
+            columns=columns,
+            rows=[dict(zip(columns, row)) for row in rows],
+            truncated=len(rows) >= max_rows,
+        )
+
+    # -- metadata 
--------------------------------------------------------------
+    #
+    # Metadata is identical for both backends: it is plain SQL over a `query`
+    # callable, including the file-plugin branching. Task 6's implementations
+    # were extracted into module-level functions in `client_rest.py` that take
+    # a query callable, so both clients delegate to the same functions rather
+    # than duplicating the security-relevant identifier quoting.
+
+    def plugin_type(self, schema: str) -> str | None:
+        return fetch_plugin_type(self.query, schema)
+
+    def schemas(self) -> list[dict[str, Any]]:
+        return fetch_schemas(self.query)
+
+    def tables(self, schema: str) -> list[dict[str, Any]]:
+        return fetch_tables(self.query, schema)
+
+    def columns(self, schema: str, table: str) -> list[dict[str, Any]]:
+        return fetch_columns(self.query, schema, table)
diff --git a/tests/test_client_jdbc.py b/tests/test_client_jdbc.py
new file mode 100644
index 0000000..3fd8011
--- /dev/null
+++ b/tests/test_client_jdbc.py
@@ -0,0 +1,140 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+# either express or implied.  See the License for the specific
+# language governing permissions and limitations under the
+# License.
+#
+
+import sys
+from unittest.mock import MagicMock
+
+import pytest
+
+from drill_mcp.client_jdbc import JdbcClient
+from drill_mcp.client_rest import DrillError
+from drill_mcp.config import load_config
+
+
[email protected]
+def fake_jaydebeapi(monkeypatch):
+    module = MagicMock()
+    cursor = MagicMock()
+    cursor.description = [("a", None), ("b", None)]
+    cursor.fetchmany.return_value = [(1, "x")]
+    connection = MagicMock()
+    connection.cursor.return_value = cursor
+    module.connect.return_value = connection
+    monkeypatch.setitem(sys.modules, "jaydebeapi", module)
+    return module
+
+
+def make_client(**overrides):
+    overrides.setdefault("backend", "jdbc")
+    overrides.setdefault("jdbc_driver_path", "/opt/drill-jdbc-all.jar")
+    return JdbcClient(load_config(overrides=overrides))
+
+
+def test_clear_error_when_extra_is_not_installed(monkeypatch):
+    monkeypatch.setitem(sys.modules, "jaydebeapi", None)
+    with pytest.raises(DrillError, match=r"drill-mcp\[jdbc\]"):
+        make_client().query("SELECT 1", max_rows=1)
+
+
+def test_connect_uses_the_configured_driver_and_url(fake_jaydebeapi):
+    make_client(url="http://drill:8047";).query("SELECT 1", max_rows=1)
+    args = fake_jaydebeapi.connect.call_args
+    assert args.args[0] == "org.apache.drill.jdbc.Driver"
+    assert args.args[1].startswith("jdbc:drill:")
+    assert args.kwargs["jars"] == ["/opt/drill-jdbc-all.jar"]
+
+
+def test_query_returns_columns_and_rows(fake_jaydebeapi):
+    result = make_client().query("SELECT 1", max_rows=10)
+    assert result.columns == ["a", "b"]
+    assert result.rows == [{"a": 1, "b": "x"}]
+    assert result.truncated is False
+
+
+def test_query_respects_max_rows(fake_jaydebeapi):
+    cursor = fake_jaydebeapi.connect.return_value.cursor.return_value
+    cursor.fetchmany.return_value = [(1, "x"), (2, "y")]
+    result = make_client().query("SELECT 1", max_rows=2)
+    cursor.fetchmany.assert_called_with(2)
+    assert result.truncated is True
+
+
+def test_connection_is_reused(fake_jaydebeapi):
+    client = make_client()
+    client.query("SELECT 1", max_rows=1)
+    client.query("SELECT 2", max_rows=1)
+    assert fake_jaydebeapi.connect.call_count == 1
+
+
+def test_basic_auth_credentials_are_passed(fake_jaydebeapi):
+    make_client(auth="basic", user="alice", password="s3cret").query("SELECT 
1", max_rows=1)
+    assert fake_jaydebeapi.connect.call_args.args[2] == ["alice", "s3cret"]
+
+
+def test_kerberos_sets_the_auth_property(fake_jaydebeapi):
+    make_client(auth="kerberos").query("SELECT 1", max_rows=1)
+    assert "auth=kerberos" in fake_jaydebeapi.connect.call_args.args[1]
+
+
+def test_schemas_uses_information_schema(fake_jaydebeapi):
+    cursor = fake_jaydebeapi.connect.return_value.cursor.return_value
+    cursor.description = [("SCHEMA_NAME", None), ("TYPE", None)]
+    cursor.fetchmany.return_value = [("dfs.tmp", "file")]
+    assert make_client().schemas() == [{"name": "dfs.tmp", "type": "file"}]
+
+
+def test_tables_rejects_injection(fake_jaydebeapi):
+    with pytest.raises(DrillError, match="invalid identifier"):
+        make_client().tables("dfs'; DROP TABLE x --")
+
+
+def test_driver_error_is_wrapped(fake_jaydebeapi):
+    fake_jaydebeapi.connect.side_effect = RuntimeError("no route to host")
+    with pytest.raises(DrillError, match="no route to host"):
+        make_client().query("SELECT 1", max_rows=1)
+
+
+def test_close_closes_the_connection(fake_jaydebeapi):
+    client = make_client()
+    client.query("SELECT 1", max_rows=1)
+    client.close()
+    fake_jaydebeapi.connect.return_value.close.assert_called_once()
+
+
+def test_close_is_safe_before_connecting():
+    make_client().close()  # does not raise
+
+
+def test_driver_error_does_not_leak_the_password(fake_jaydebeapi):
+    fake_jaydebeapi.connect.side_effect = RuntimeError("auth failed for user 
alice/s3cret")
+    with pytest.raises(DrillError) as exc_info:
+        make_client(auth="basic", user="alice", 
password="s3cret").query("SELECT 1", max_rows=1)
+    # DrillError wraps whatever the driver reports; the client itself must
+    # never independently add the password into the message. This asserts
+    # the message is exactly the driver's own text, not a client-composed
+    # string that embeds config.password.
+    assert str(exc_info.value) == (
+        "could not connect to Drill over JDBC: auth failed for user 
alice/s3cret"
+    )
+
+
+def test_management_methods_are_not_implemented(fake_jaydebeapi):
+    client = make_client()
+    for name in ("storage_plugins", "cluster_status", "profiles", "profile", 
"cancel_query"):
+        assert not hasattr(client, name)

Reply via email to