| from __future__ import annotations | |
| import sys | |
| import unittest | |
| import json | |
| import logging | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| from unittest.mock import patch | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT / "src")) | |
| from iotdb_mcp_server.config import Config # noqa: E402 | |
| from iotdb_mcp_server.result_store import FileResultStore # noqa: E402 | |
| from iotdb_mcp_server.result_store import StoredCsvResult # noqa: E402 | |
| from iotdb_mcp_server.services.sql_driver import ( # noqa: E402 | |
| _classify_sql, | |
| _render_sql_template, | |
| _resolve_batch_sqls, | |
| _resolve_whitelists, | |
| _stored_result_payload, | |
| register_sql_driver_tools, | |
| ) | |
| class FakeMCP: | |
| def __init__(self) -> None: | |
| self.tools = {} | |
| def tool(self): | |
| def decorator(fn): | |
| self.tools[fn.__name__] = fn | |
| return fn | |
| return decorator | |
| class FakeRecord: | |
| def __init__(self, fields: list[str]) -> None: | |
| self._fields = fields | |
| def get_fields(self) -> list[str]: | |
| return self._fields | |
| class FakeDataSet: | |
| def __init__(self, columns: list[str], rows: list[list[str]]) -> None: | |
| self._columns = columns | |
| self._rows = rows | |
| self._index = 0 | |
| def get_column_names(self) -> list[str]: | |
| return self._columns | |
| def has_next(self) -> bool: | |
| return self._index < len(self._rows) | |
| def next(self) -> FakeRecord: | |
| row = self._rows[self._index] | |
| self._index += 1 | |
| return FakeRecord(row) | |
| class FakeSession: | |
| def __init__(self) -> None: | |
| self.closed = False | |
| def execute_query_statement(self, sql: str) -> FakeDataSet: | |
| return FakeDataSet( | |
| ["timeseries"], | |
| [[f"{sql}:row0"], [f"{sql}:row1"], [f"{sql}:row2"]], | |
| ) | |
| def close(self) -> None: | |
| self.closed = True | |
| class SlowSession(FakeSession): | |
| def execute_query_statement(self, sql: str) -> FakeDataSet: | |
| time.sleep(0.05) | |
| return super().execute_query_statement(sql) | |
| class FakePool: | |
| def __init__(self, session_cls=FakeSession) -> None: | |
| self.session_cls = session_cls | |
| def get_session(self) -> FakeSession: | |
| return self.session_cls() | |
| class SqlDriverBatchTest(unittest.TestCase): | |
| def test_tree_select_into_requires_full_permission(self) -> None: | |
| category, matched_prefix = _classify_sql( | |
| "SELECT s1 INTO root.dest.d1(s1) FROM root.source.d1", | |
| _resolve_whitelists("tree"), | |
| ) | |
| self.assertEqual(category, "full") | |
| self.assertEqual(matched_prefix, "SELECT INTO") | |
| def test_select_literal_containing_into_remains_readonly(self) -> None: | |
| category, matched_prefix = _classify_sql( | |
| "SELECT 'INTO' AS label FROM root.source.d1", | |
| _resolve_whitelists("tree"), | |
| ) | |
| self.assertEqual(category, "readonly") | |
| self.assertEqual(matched_prefix, "SELECT") | |
| def test_resolve_direct_sqls_normalizes_single_statements(self) -> None: | |
| statements = _resolve_batch_sqls( | |
| [" SHOW TIMESERIES root.** LIMIT 1; ", "SELECT s1 FROM root.sg.d1"], | |
| None, | |
| None, | |
| ) | |
| self.assertEqual( | |
| [item["sql"] for item in statements], | |
| ["SHOW TIMESERIES root.** LIMIT 1", "SELECT s1 FROM root.sg.d1"], | |
| ) | |
| self.assertEqual([item["index"] for item in statements], [0, 1]) | |
| def test_resolve_direct_sqls_rejects_multi_statement(self) -> None: | |
| with self.assertRaisesRegex(ValueError, "single SQL statement"): | |
| _resolve_batch_sqls(["SELECT 1; SELECT 2"], None, None) | |
| def test_render_template_supports_literals_paths_and_identifiers(self) -> None: | |
| sql = _render_sql_template( | |
| "SELECT {{measurement:identifier}} FROM {{device:path}} " | |
| "WHERE time >= {{start}} AND time < {{end}}", | |
| { | |
| "measurement": "s1", | |
| "device": "root.BFGDGS.DATA.d1", | |
| "start": 0, | |
| "end": "2026-07-06T00:00:00", | |
| }, | |
| ) | |
| self.assertEqual( | |
| sql, | |
| "SELECT s1 FROM root.BFGDGS.DATA.d1 " | |
| "WHERE time >= 0 AND time < '2026-07-06T00:00:00'", | |
| ) | |
| def test_render_template_rejects_unsafe_path(self) -> None: | |
| with self.assertRaisesRegex(ValueError, "Unsafe IoTDB path"): | |
| _render_sql_template( | |
| "SELECT s1 FROM {{device:path}}", | |
| {"device": "root.sg.d1; DELETE TIMESERIES root.**"}, | |
| ) | |
| def test_resolve_template_param_sets(self) -> None: | |
| statements = _resolve_batch_sqls( | |
| None, | |
| "SHOW TIMESERIES {{path:path}} LIMIT {{limit}}", | |
| [ | |
| {"path": "root.BFGDGS.DATA.**", "limit": 1}, | |
| {"path": "root.BFGDGS.DATA.device1.*", "limit": 2}, | |
| ], | |
| ) | |
| self.assertEqual( | |
| [item["sql"] for item in statements], | |
| [ | |
| "SHOW TIMESERIES root.BFGDGS.DATA.** LIMIT 1", | |
| "SHOW TIMESERIES root.BFGDGS.DATA.device1.* LIMIT 2", | |
| ], | |
| ) | |
| def test_stored_result_payload_is_pageable(self) -> None: | |
| stored = StoredCsvResult( | |
| result_id="res_20260706T000000Z_0123456789abcdef", | |
| owner_session_id="ses_batch", | |
| columns=["Time", "root.sg.d1.s1"], | |
| preview_rows=["0,1"], | |
| row_count=3, | |
| byte_count=12, | |
| page_size_rows=500, | |
| manifest_path="/tmp/result_store/manifest.json", | |
| shard_count=1, | |
| ) | |
| payload = _stored_result_payload( | |
| index=0, | |
| sql="SELECT s1 FROM root.sg.d1", | |
| stored=stored, | |
| columns=stored.columns, | |
| duration_ms=12, | |
| ) | |
| self.assertTrue(payload["ok"]) | |
| self.assertEqual(payload["result_id"], stored.result_id) | |
| self.assertEqual(payload["next_cursor"], "1") | |
| self.assertEqual(payload["result_store"]["page_tool"], "read_result_page") | |
| def test_sql_executor_batch_tool_writes_result_store_entries(self) -> None: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| cfg = Config( | |
| host="127.0.0.1", | |
| port=6667, | |
| user="root", | |
| password="", | |
| database="", | |
| sql_dialect="tree", | |
| timezone="Asia/Shanghai", | |
| export_path=tmp, | |
| ) | |
| fake_mcp = FakeMCP() | |
| register_sql_driver_tools( | |
| fake_mcp, | |
| cfg, | |
| logging.getLogger("test_sql_executor_batch"), | |
| ) | |
| with patch( | |
| "iotdb_mcp_server.services.sql_driver.tree_session_pool", | |
| return_value=(cfg, FakePool()), | |
| ), patch( | |
| "iotdb_mcp_server.services.target_selection._probe_ainode_availability", | |
| return_value={ | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": {"method": "test", "ok": False}, | |
| }, | |
| ): | |
| import asyncio | |
| response = asyncio.run( | |
| fake_mcp.tools["sql_executor_batch"]( | |
| sqls=[ | |
| "SHOW TIMESERIES root.sg.** LIMIT 2", | |
| "SHOW DEVICES root.sg.** LIMIT 2", | |
| ], | |
| max_concurrency=2, | |
| worker_pool_size=2, | |
| owner_session_id="ses_tool", | |
| max_inline_rows=1, | |
| page_size_rows=2, | |
| ) | |
| ) | |
| obj = json.loads(response[-1].text) | |
| payload = obj["payload"] | |
| self.assertEqual(payload["succeeded"], 2) | |
| self.assertEqual(payload["failed"], 0) | |
| self.assertEqual(payload["worker_pool_size"], 2) | |
| self.assertEqual(payload["execution_controls"]["per_item_timeout_ms"], 60000) | |
| self.assertEqual(payload["execution_controls"]["max_result_rows_per_item"], 10000) | |
| first = payload["results"][0] | |
| self.assertEqual(first["inline_row_count"], 1) | |
| self.assertTrue(first["inline_truncated"]) | |
| store = FileResultStore(Path(tmp) / "result_store") | |
| page = store.read_page( | |
| first["result_id"], | |
| offset=1, | |
| limit=2, | |
| owner_session_id="ses_tool", | |
| ) | |
| self.assertEqual(page["returned_rows"], 2) | |
| self.assertFalse(page["has_more"]) | |
| def test_sql_executor_batch_enforces_per_item_row_quota(self) -> None: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| cfg = Config( | |
| host="127.0.0.1", | |
| port=6667, | |
| user="root", | |
| password="", | |
| database="", | |
| sql_dialect="tree", | |
| timezone="Asia/Shanghai", | |
| export_path=tmp, | |
| ) | |
| fake_mcp = FakeMCP() | |
| register_sql_driver_tools( | |
| fake_mcp, | |
| cfg, | |
| logging.getLogger("test_sql_executor_batch_quota"), | |
| ) | |
| with patch( | |
| "iotdb_mcp_server.services.sql_driver.tree_session_pool", | |
| return_value=(cfg, FakePool()), | |
| ), patch( | |
| "iotdb_mcp_server.services.target_selection._probe_ainode_availability", | |
| return_value={ | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": {"method": "test", "ok": False}, | |
| }, | |
| ): | |
| import asyncio | |
| response = asyncio.run( | |
| fake_mcp.tools["sql_executor_batch"]( | |
| sqls=["SHOW TIMESERIES root.sg.** LIMIT 3"], | |
| max_result_rows_per_item=2, | |
| owner_session_id="ses_quota", | |
| ) | |
| ) | |
| payload = json.loads(response[-1].text)["payload"] | |
| self.assertEqual(payload["succeeded"], 0) | |
| self.assertEqual(payload["failed"], 1) | |
| self.assertEqual(payload["results"][0]["error_type"], "BatchQuotaExceeded") | |
| self.assertIn("row quota exceeded", payload["results"][0]["error"]) | |
| def test_sql_executor_batch_enforces_per_item_timeout(self) -> None: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| cfg = Config( | |
| host="127.0.0.1", | |
| port=6667, | |
| user="root", | |
| password="", | |
| database="", | |
| sql_dialect="tree", | |
| timezone="Asia/Shanghai", | |
| export_path=tmp, | |
| ) | |
| fake_mcp = FakeMCP() | |
| register_sql_driver_tools( | |
| fake_mcp, | |
| cfg, | |
| logging.getLogger("test_sql_executor_batch_timeout"), | |
| ) | |
| with patch( | |
| "iotdb_mcp_server.services.sql_driver.tree_session_pool", | |
| return_value=(cfg, FakePool(SlowSession)), | |
| ), patch( | |
| "iotdb_mcp_server.services.target_selection._probe_ainode_availability", | |
| return_value={ | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": {"method": "test", "ok": False}, | |
| }, | |
| ): | |
| import asyncio | |
| response = asyncio.run( | |
| fake_mcp.tools["sql_executor_batch"]( | |
| sqls=["SHOW TIMESERIES root.sg.** LIMIT 3"], | |
| per_item_timeout_ms=1, | |
| owner_session_id="ses_timeout", | |
| ) | |
| ) | |
| payload = json.loads(response[-1].text)["payload"] | |
| self.assertEqual(payload["succeeded"], 0) | |
| self.assertEqual(payload["failed"], 1) | |
| self.assertEqual(payload["results"][0]["error_type"], "BatchItemTimeout") | |
| self.assertIn("timed out", payload["results"][0]["error"]) | |
| def test_sql_executor_batch_enforces_total_row_quota(self) -> None: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| cfg = Config( | |
| host="127.0.0.1", | |
| port=6667, | |
| user="root", | |
| password="", | |
| database="", | |
| sql_dialect="tree", | |
| timezone="Asia/Shanghai", | |
| export_path=tmp, | |
| ) | |
| fake_mcp = FakeMCP() | |
| register_sql_driver_tools( | |
| fake_mcp, | |
| cfg, | |
| logging.getLogger("test_sql_executor_batch_total_quota"), | |
| ) | |
| with patch( | |
| "iotdb_mcp_server.services.sql_driver.tree_session_pool", | |
| return_value=(cfg, FakePool()), | |
| ), patch( | |
| "iotdb_mcp_server.services.target_selection._probe_ainode_availability", | |
| return_value={ | |
| "ainode_available": "unknown", | |
| "ainode_availability_source": {"method": "test", "ok": False}, | |
| }, | |
| ): | |
| import asyncio | |
| response = asyncio.run( | |
| fake_mcp.tools["sql_executor_batch"]( | |
| sqls=[ | |
| "SHOW TIMESERIES root.sg.** LIMIT 3", | |
| "SHOW DEVICES root.sg.** LIMIT 3", | |
| ], | |
| max_concurrency=1, | |
| max_batch_result_rows=4, | |
| owner_session_id="ses_total_quota", | |
| ) | |
| ) | |
| payload = json.loads(response[-1].text)["payload"] | |
| self.assertEqual(payload["succeeded"], 1) | |
| self.assertEqual(payload["failed"], 1) | |
| self.assertEqual(payload["results"][1]["error_type"], "BatchQuotaExceeded") | |
| self.assertIn("batch row quota exceeded", payload["results"][1]["error"]) | |
| if __name__ == "__main__": | |
| unittest.main() |