feat: add direct database access for scripts (v0.2.0)
Implement persistent SQLite database feature that allows scripts to query schedule data directly via SQL after loading XER files through MCP. Key changes: - Extend load_xer with db_path parameter for persistent database - Add get_database_info tool to retrieve database connection details - Add schema introspection with tables, columns, primary/foreign keys - Support WAL mode for concurrent read access - Use atomic write pattern to prevent corruption New features: - db_path=None: in-memory database (default, backward compatible) - db_path="": auto-generate path from XER filename (.sqlite extension) - db_path="/path/to/db": explicit persistent database path Response includes complete DatabaseInfo: - db_path: absolute path (or :memory:) - is_persistent: boolean - source_file: loaded XER path - loaded_at: ISO timestamp - schema: tables with columns, primary keys, foreign keys, row counts Closes: User Story 1, 2, 3 from 002-direct-db-access spec
This commit is contained in:
143
tests/contract/test_get_database_info.py
Normal file
143
tests/contract/test_get_database_info.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Contract tests for get_database_info MCP tool."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Reset database state for each test."""
|
||||
if db.is_initialized:
|
||||
db.close()
|
||||
yield
|
||||
if db.is_initialized:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestGetDatabaseInfoContract:
|
||||
"""Contract tests verifying get_database_info tool interface."""
|
||||
|
||||
async def test_get_database_info_returns_current_database(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_database_info returns info about currently loaded database."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert "database" in result
|
||||
assert result["database"]["db_path"] == str(db_file)
|
||||
assert result["database"]["is_persistent"] is True
|
||||
|
||||
async def test_get_database_info_error_when_no_database(self) -> None:
|
||||
"""get_database_info returns error when no database loaded."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
|
||||
# Ensure database is not initialized
|
||||
if db.is_initialized:
|
||||
db.close()
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"]["code"] == "NO_FILE_LOADED"
|
||||
|
||||
async def test_get_database_info_includes_schema(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_database_info includes schema information."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert "schema" in result["database"]
|
||||
assert "tables" in result["database"]["schema"]
|
||||
|
||||
async def test_get_database_info_includes_loaded_at(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_database_info includes loaded_at timestamp."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert "loaded_at" in result["database"]
|
||||
# Should be ISO format timestamp
|
||||
assert "T" in result["database"]["loaded_at"]
|
||||
|
||||
async def test_get_database_info_includes_source_file(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_database_info includes source XER file path."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert result["database"]["source_file"] == str(sample_xer_single_project)
|
||||
|
||||
async def test_get_database_info_for_memory_database(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""get_database_info works for in-memory database."""
|
||||
from xer_mcp.tools.get_database_info import get_database_info
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
result = await get_database_info()
|
||||
|
||||
assert "database" in result
|
||||
assert result["database"]["db_path"] == ":memory:"
|
||||
assert result["database"]["is_persistent"] is False
|
||||
|
||||
|
||||
class TestGetDatabaseInfoToolSchema:
|
||||
"""Tests for MCP tool schema."""
|
||||
|
||||
async def test_get_database_info_tool_registered(self) -> None:
|
||||
"""get_database_info tool is registered with MCP server."""
|
||||
from xer_mcp.server import list_tools
|
||||
|
||||
tools = await list_tools()
|
||||
tool_names = [t.name for t in tools]
|
||||
assert "get_database_info" in tool_names
|
||||
|
||||
async def test_get_database_info_tool_has_empty_input_schema(self) -> None:
|
||||
"""get_database_info tool has no required inputs."""
|
||||
from xer_mcp.server import list_tools
|
||||
|
||||
tools = await list_tools()
|
||||
tool = next(t for t in tools if t.name == "get_database_info")
|
||||
# Should have empty or no required properties
|
||||
assert "required" not in tool.inputSchema or len(tool.inputSchema.get("required", [])) == 0
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Contract tests for load_xer MCP tool."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -9,10 +10,14 @@ from xer_mcp.db import db
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
"""Reset database state for each test."""
|
||||
# Close any existing connection
|
||||
if db.is_initialized:
|
||||
db.close()
|
||||
yield
|
||||
db.clear()
|
||||
# Cleanup after test
|
||||
if db.is_initialized:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestLoadXerContract:
|
||||
@@ -105,3 +110,141 @@ class TestLoadXerContract:
|
||||
assert "plan_end_date" in result["project"]
|
||||
# Dates should be ISO8601 format
|
||||
assert "T" in result["project"]["plan_start_date"]
|
||||
|
||||
|
||||
class TestLoadXerPersistentDatabase:
|
||||
"""Contract tests for persistent database functionality."""
|
||||
|
||||
async def test_load_xer_with_db_path_creates_file(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""load_xer with db_path creates persistent database file."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
result = await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert db_file.exists()
|
||||
assert result["database"]["db_path"] == str(db_file)
|
||||
assert result["database"]["is_persistent"] is True
|
||||
|
||||
async def test_load_xer_with_empty_db_path_auto_generates(self, tmp_path: Path) -> None:
|
||||
"""load_xer with empty db_path generates path from XER filename."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
# Create XER file in tmp_path
|
||||
xer_file = tmp_path / "my_schedule.xer"
|
||||
from tests.conftest import SAMPLE_XER_SINGLE_PROJECT
|
||||
|
||||
xer_file.write_text(SAMPLE_XER_SINGLE_PROJECT)
|
||||
|
||||
result = await load_xer(file_path=str(xer_file), db_path="")
|
||||
|
||||
assert result["success"] is True
|
||||
expected_db = str(tmp_path / "my_schedule.sqlite")
|
||||
assert result["database"]["db_path"] == expected_db
|
||||
assert result["database"]["is_persistent"] is True
|
||||
assert Path(expected_db).exists()
|
||||
|
||||
async def test_load_xer_without_db_path_uses_memory(
|
||||
self, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""load_xer without db_path uses in-memory database (backward compatible)."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(file_path=str(sample_xer_single_project))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["database"]["db_path"] == ":memory:"
|
||||
assert result["database"]["is_persistent"] is False
|
||||
|
||||
async def test_load_xer_database_contains_all_data(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""Persistent database contains all parsed data."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
result = await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
# Verify data via direct SQL
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM activities")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
assert count == result["activity_count"]
|
||||
|
||||
async def test_load_xer_response_includes_database_info(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""load_xer response includes complete database info."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
result = await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
assert "database" in result
|
||||
db_info = result["database"]
|
||||
assert "db_path" in db_info
|
||||
assert "is_persistent" in db_info
|
||||
assert "source_file" in db_info
|
||||
assert "loaded_at" in db_info
|
||||
assert "schema" in db_info
|
||||
|
||||
async def test_load_xer_response_schema_includes_tables(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""load_xer response schema includes table information."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
db_file = tmp_path / "schedule.db"
|
||||
result = await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path=str(db_file),
|
||||
)
|
||||
|
||||
schema = result["database"]["schema"]
|
||||
assert "version" in schema
|
||||
assert "tables" in schema
|
||||
table_names = [t["name"] for t in schema["tables"]]
|
||||
assert "activities" in table_names
|
||||
assert "relationships" in table_names
|
||||
|
||||
async def test_load_xer_error_on_invalid_path(self, sample_xer_single_project: Path) -> None:
|
||||
"""load_xer returns error for invalid path."""
|
||||
from xer_mcp.tools.load_xer import load_xer
|
||||
|
||||
result = await load_xer(
|
||||
file_path=str(sample_xer_single_project),
|
||||
db_path="/nonexistent/dir/file.db",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
# Either FILE_NOT_WRITABLE or DATABASE_ERROR is acceptable
|
||||
# depending on how SQLite reports the error
|
||||
assert result["error"]["code"] in ("FILE_NOT_WRITABLE", "DATABASE_ERROR")
|
||||
|
||||
|
||||
class TestLoadXerToolSchema:
|
||||
"""Tests for MCP tool schema."""
|
||||
|
||||
async def test_load_xer_tool_schema_includes_db_path(self) -> None:
|
||||
"""MCP tool schema includes db_path parameter."""
|
||||
from xer_mcp.server import list_tools
|
||||
|
||||
tools = await list_tools()
|
||||
load_xer_tool = next(t for t in tools if t.name == "load_xer")
|
||||
props = load_xer_tool.inputSchema["properties"]
|
||||
assert "db_path" in props
|
||||
assert props["db_path"]["type"] == "string"
|
||||
|
||||
Reference in New Issue
Block a user