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:
185
tests/integration/test_direct_db_access.py
Normal file
185
tests/integration/test_direct_db_access.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Integration tests for direct database access feature."""
|
||||
|
||||
import sqlite3
|
||||
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 TestDirectDatabaseAccess:
|
||||
"""Integration tests verifying external script can access database."""
|
||||
|
||||
async def test_external_script_can_query_database(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""External script can query database using returned path."""
|
||||
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),
|
||||
)
|
||||
|
||||
# Simulate external script access (as shown in quickstart.md)
|
||||
db_path = result["database"]["db_path"]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Query milestones
|
||||
cursor = conn.execute("""
|
||||
SELECT task_code, task_name, target_start_date, milestone_type
|
||||
FROM activities
|
||||
WHERE task_type IN ('TT_Mile', 'TT_FinMile')
|
||||
ORDER BY target_start_date
|
||||
""")
|
||||
|
||||
milestones = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
assert len(milestones) > 0
|
||||
assert all(row["task_code"] for row in milestones)
|
||||
|
||||
async def test_external_script_can_query_critical_path(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""External script can query critical path activities."""
|
||||
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),
|
||||
)
|
||||
|
||||
db_path = result["database"]["db_path"]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute("""
|
||||
SELECT task_code, task_name, target_start_date, target_end_date
|
||||
FROM activities
|
||||
WHERE driving_path_flag = 1
|
||||
ORDER BY target_start_date
|
||||
""")
|
||||
|
||||
critical_activities = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
assert len(critical_activities) > 0
|
||||
|
||||
async def test_external_script_can_join_tables(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""External script can join activities with WBS."""
|
||||
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),
|
||||
)
|
||||
|
||||
db_path = result["database"]["db_path"]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute("""
|
||||
SELECT a.task_code, a.task_name, w.wbs_name
|
||||
FROM activities a
|
||||
JOIN wbs w ON a.wbs_id = w.wbs_id
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
joined_rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
assert len(joined_rows) > 0
|
||||
|
||||
async def test_database_accessible_after_mcp_load(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""Database remains accessible while MCP tools are active."""
|
||||
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),
|
||||
)
|
||||
loaded_count = result["activity_count"]
|
||||
|
||||
# External script queries database
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM activities")
|
||||
external_count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
# Both should match
|
||||
assert external_count == loaded_count
|
||||
|
||||
async def test_schema_info_matches_actual_database(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""Returned schema info matches actual database structure."""
|
||||
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"]
|
||||
db_path = result["database"]["db_path"]
|
||||
|
||||
# Verify tables exist in actual database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
)
|
||||
actual_tables = {row[0] for row in cursor.fetchall()}
|
||||
conn.close()
|
||||
|
||||
schema_tables = {t["name"] for t in schema["tables"]}
|
||||
assert schema_tables == actual_tables
|
||||
|
||||
async def test_row_counts_match_actual_data(
|
||||
self, tmp_path: Path, sample_xer_single_project: Path
|
||||
) -> None:
|
||||
"""Schema row counts match actual database row counts."""
|
||||
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"]
|
||||
db_path = result["database"]["db_path"]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
for table_info in schema["tables"]:
|
||||
cursor = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {table_info['name']}" # noqa: S608
|
||||
)
|
||||
actual_count = cursor.fetchone()[0]
|
||||
assert table_info["row_count"] == actual_count, (
|
||||
f"Table {table_info['name']}: expected {table_info['row_count']}, "
|
||||
f"got {actual_count}"
|
||||
)
|
||||
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user