LIANJie-Jason Claude Opus 4.6 commited on
Commit
54bc2bb
·
1 Parent(s): 863e8aa

feat: add SQL retriever — validated read-only query execution

Browse files
Files changed (2) hide show
  1. src/sql_retriever.py +61 -0
  2. tests/test_sql_retriever.py +105 -0
src/sql_retriever.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQL retriever: execute validated queries against the SQLite knowledge base."""
2
+
3
+ import os
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+
8
+ def _validate_sql(sql: str) -> bool:
9
+ """Validate that a SQL string is a safe SELECT query.
10
+
11
+ Rejects non-SELECT statements and queries containing semicolons
12
+ (which could chain dangerous statements).
13
+ """
14
+ stripped = sql.strip()
15
+ if not stripped:
16
+ return False
17
+ if ";" in stripped:
18
+ return False
19
+ if not stripped.upper().startswith("SELECT"):
20
+ return False
21
+ return True
22
+
23
+
24
+ def _get_db_path(cfg: dict) -> str:
25
+ """Resolve the SQLite database path from config."""
26
+ sql_db_dir = cfg.get("paths", {}).get("sql_db", "sql_db")
27
+ if not os.path.isabs(sql_db_dir):
28
+ project_root = Path(__file__).resolve().parent.parent
29
+ sql_db_dir = os.path.join(str(project_root), sql_db_dir)
30
+ return os.path.join(sql_db_dir, "knowledge_base.db")
31
+
32
+
33
+ def execute_sql_query(sql_query: str, cfg: dict) -> list[dict]:
34
+ """Execute a validated SELECT query against the knowledge base SQLite DB.
35
+
36
+ Args:
37
+ sql_query: A SQL SELECT query (generated by the LLM).
38
+ cfg: App config dict.
39
+
40
+ Returns:
41
+ List of row dicts, or empty list on any error.
42
+ """
43
+ if not _validate_sql(sql_query):
44
+ return []
45
+
46
+ db_path = _get_db_path(cfg)
47
+ if not os.path.exists(db_path):
48
+ return []
49
+
50
+ max_rows = cfg.get("sql", {}).get("max_rows", 200)
51
+
52
+ try:
53
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
54
+ conn.row_factory = sqlite3.Row
55
+ cursor = conn.execute(sql_query)
56
+ rows = cursor.fetchmany(max_rows)
57
+ result = [dict(row) for row in rows]
58
+ conn.close()
59
+ return result
60
+ except Exception:
61
+ return []
tests/test_sql_retriever.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_sql_retriever.py
2
+ import pytest
3
+ import json
4
+ import sqlite3
5
+
6
+
7
+ def test_validate_sql_rejects_non_select():
8
+ from src.sql_retriever import _validate_sql
9
+ assert _validate_sql("DROP TABLE users") is False
10
+ assert _validate_sql("INSERT INTO t VALUES (1)") is False
11
+ assert _validate_sql("UPDATE t SET x=1") is False
12
+ assert _validate_sql("DELETE FROM t") is False
13
+
14
+
15
+ def test_validate_sql_rejects_semicolons():
16
+ from src.sql_retriever import _validate_sql
17
+ assert _validate_sql("SELECT 1; DROP TABLE users") is False
18
+
19
+
20
+ def test_validate_sql_accepts_select():
21
+ from src.sql_retriever import _validate_sql
22
+ assert _validate_sql("SELECT * FROM t") is True
23
+ assert _validate_sql(" select Country, Year from t WHERE x = 1 ") is True
24
+ assert _validate_sql("SELECT COUNT(*) FROM t GROUP BY x") is True
25
+
26
+
27
+ def test_validate_sql_rejects_empty():
28
+ from src.sql_retriever import _validate_sql
29
+ assert _validate_sql("") is False
30
+ assert _validate_sql(" ") is False
31
+
32
+
33
+ def test_execute_sql_query_returns_rows(tmp_path):
34
+ from src.sql_retriever import execute_sql_query
35
+
36
+ # Create a test DB
37
+ db_path = tmp_path / "sql_db" / "knowledge_base.db"
38
+ db_path.parent.mkdir()
39
+ conn = sqlite3.connect(str(db_path))
40
+ conn.execute("CREATE TABLE t (Country TEXT, Year INTEGER, Score REAL)")
41
+ conn.execute("INSERT INTO t VALUES ('China', 2005, 4.0)")
42
+ conn.execute("INSERT INTO t VALUES ('India', 2005, 3.0)")
43
+ conn.commit()
44
+ conn.close()
45
+
46
+ cfg = {"paths": {"sql_db": str(tmp_path / "sql_db")}, "sql": {"max_rows": 200}}
47
+ rows = execute_sql_query("SELECT * FROM t WHERE Country = 'China'", cfg)
48
+ assert len(rows) == 1
49
+ assert rows[0]["Country"] == "China"
50
+ assert rows[0]["Score"] == 4.0
51
+
52
+
53
+ def test_execute_sql_query_respects_max_rows(tmp_path):
54
+ from src.sql_retriever import execute_sql_query
55
+
56
+ db_path = tmp_path / "sql_db" / "knowledge_base.db"
57
+ db_path.parent.mkdir()
58
+ conn = sqlite3.connect(str(db_path))
59
+ conn.execute("CREATE TABLE t (x INTEGER)")
60
+ for i in range(100):
61
+ conn.execute("INSERT INTO t VALUES (?)", (i,))
62
+ conn.commit()
63
+ conn.close()
64
+
65
+ cfg = {"paths": {"sql_db": str(tmp_path / "sql_db")}, "sql": {"max_rows": 5}}
66
+ rows = execute_sql_query("SELECT * FROM t", cfg)
67
+ assert len(rows) == 5
68
+
69
+
70
+ def test_execute_sql_query_rejects_non_select(tmp_path):
71
+ from src.sql_retriever import execute_sql_query
72
+
73
+ db_path = tmp_path / "sql_db" / "knowledge_base.db"
74
+ db_path.parent.mkdir()
75
+ conn = sqlite3.connect(str(db_path))
76
+ conn.execute("CREATE TABLE t (x INTEGER)")
77
+ conn.commit()
78
+ conn.close()
79
+
80
+ cfg = {"paths": {"sql_db": str(tmp_path / "sql_db")}}
81
+ rows = execute_sql_query("DROP TABLE t", cfg)
82
+ assert rows == []
83
+
84
+
85
+ def test_execute_sql_query_handles_bad_sql(tmp_path):
86
+ from src.sql_retriever import execute_sql_query
87
+
88
+ db_path = tmp_path / "sql_db" / "knowledge_base.db"
89
+ db_path.parent.mkdir()
90
+ conn = sqlite3.connect(str(db_path))
91
+ conn.execute("CREATE TABLE t (x INTEGER)")
92
+ conn.commit()
93
+ conn.close()
94
+
95
+ cfg = {"paths": {"sql_db": str(tmp_path / "sql_db")}}
96
+ rows = execute_sql_query("SELECT * FROM nonexistent_table", cfg)
97
+ assert rows == []
98
+
99
+
100
+ def test_execute_sql_query_no_db(tmp_path):
101
+ from src.sql_retriever import execute_sql_query
102
+
103
+ cfg = {"paths": {"sql_db": str(tmp_path / "sql_db_missing")}}
104
+ rows = execute_sql_query("SELECT 1", cfg)
105
+ assert rows == []