natquery 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. natquery/__init__.py +0 -0
  2. natquery/cli/__init__.py +0 -0
  3. natquery/cli/commands.py +256 -0
  4. natquery/cli/formatter.py +126 -0
  5. natquery/cli/main.py +51 -0
  6. natquery/cli/shell.py +135 -0
  7. natquery/config/__init__.py +0 -0
  8. natquery/config/connection.py +123 -0
  9. natquery/config/settings.py +100 -0
  10. natquery/execution/__init__.py +0 -0
  11. natquery/execution/engine.py +29 -0
  12. natquery/execution/explain.py +51 -0
  13. natquery/llm/__init__.py +0 -0
  14. natquery/llm/client.py +37 -0
  15. natquery/llm/self_corrector.py +84 -0
  16. natquery/ml/__init__.py +0 -0
  17. natquery/observability/__init__.py +0 -0
  18. natquery/observability/cost_analyzer.py +79 -0
  19. natquery/observability/index_recommender.py +58 -0
  20. natquery/observability/logger.py +109 -0
  21. natquery/observability/performance_api.py +58 -0
  22. natquery/orchestration/__init__.py +0 -0
  23. natquery/orchestration/error_classifier.py +29 -0
  24. natquery/orchestration/error_handler.py +81 -0
  25. natquery/orchestration/pipeline.py +192 -0
  26. natquery/orchestration/workspace.py +44 -0
  27. natquery/prompt/__init__.py +0 -0
  28. natquery/prompt/builder.py +27 -0
  29. natquery/schema/__init__.py +0 -0
  30. natquery/schema/extractor.py +80 -0
  31. natquery/schema/formatter.py +52 -0
  32. natquery/security/__init__.py +0 -0
  33. natquery/security/validator.py +20 -0
  34. natquery-1.0.0.dist-info/METADATA +147 -0
  35. natquery-1.0.0.dist-info/RECORD +39 -0
  36. natquery-1.0.0.dist-info/WHEEL +5 -0
  37. natquery-1.0.0.dist-info/entry_points.txt +2 -0
  38. natquery-1.0.0.dist-info/licenses/LICENSE +21 -0
  39. natquery-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,100 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Dict
4
+
5
+
6
+ class Settings:
7
+ """
8
+ Configuration manager:
9
+ Reads configuration from:
10
+ ~/.natquery/<active_db>/config.json
11
+ """
12
+
13
+ BASE_DIR = Path.home() / ".natquery"
14
+
15
+ @classmethod
16
+ def _get_active_db_name(cls) -> str:
17
+ current_file = cls.BASE_DIR / "current_db"
18
+
19
+ if not current_file.exists():
20
+ raise RuntimeError("No active database configured.\nRun: natquery connect")
21
+
22
+ return current_file.read_text().strip()
23
+
24
+ @classmethod
25
+ def _get_config_path(cls) -> Path:
26
+ db_name = cls._get_active_db_name()
27
+ return cls.BASE_DIR / db_name / "config.json"
28
+
29
+ @classmethod
30
+ def exists(cls) -> bool:
31
+ return cls._get_config_path().exists()
32
+
33
+ @classmethod
34
+ def load_config(cls) -> Dict:
35
+ config_path = cls._get_config_path()
36
+
37
+ if not config_path.exists():
38
+ raise RuntimeError(
39
+ "NatQuery not configured properly.\nRun: natquery connect"
40
+ )
41
+
42
+ with open(config_path, "r") as f:
43
+ return json.load(f)
44
+
45
+ @classmethod
46
+ def get_db_config(cls) -> Dict:
47
+ config = cls.load_config()
48
+
49
+ if config.get("connection_type") == "dsn":
50
+ if "db_dsn" not in config or not config["db_dsn"]:
51
+ raise ValueError("Missing database DSN.")
52
+
53
+ return {
54
+ "type": "dsn",
55
+ "dsn": config["db_dsn"],
56
+ "dbname": config.get("db_name", "unknown_db"),
57
+ }
58
+
59
+ # Default: standard connection
60
+ required = [
61
+ "db_host",
62
+ "db_port",
63
+ "db_name",
64
+ "db_user",
65
+ "db_password",
66
+ ]
67
+
68
+ for key in required:
69
+ if key not in config or not config[key]:
70
+ raise ValueError(f"Missing database config field: {key}")
71
+
72
+ return {
73
+ "type": "standard",
74
+ "host": config["db_host"],
75
+ "port": config["db_port"],
76
+ "dbname": config["db_name"],
77
+ "user": config["db_user"],
78
+ "password": config["db_password"],
79
+ "sslmode": config.get("sslmode"),
80
+ }
81
+
82
+ @classmethod
83
+ def get_llm_config(cls) -> Dict[str, str]:
84
+ config = cls.load_config()
85
+
86
+ required = [
87
+ "llm_provider",
88
+ "llm_api_key",
89
+ "llm_model",
90
+ ]
91
+
92
+ for key in required:
93
+ if key not in config or not config[key]:
94
+ raise ValueError(f"Missing LLM config field: {key}")
95
+
96
+ return {
97
+ "provider": config["llm_provider"],
98
+ "api_key": config["llm_api_key"],
99
+ "model": config["llm_model"],
100
+ }
File without changes
@@ -0,0 +1,29 @@
1
+ from natquery.config.connection import get_connection, close_connection, get_cursor
2
+
3
+
4
+ def execute_sql(sql: str):
5
+ conn = get_connection()
6
+
7
+ try:
8
+ cursor = get_cursor(conn)
9
+ cursor.execute(sql)
10
+
11
+ if cursor.description:
12
+ rows = cursor.fetchall()
13
+ # RealDictCursor rows are already dict-like, convert to plain dict
14
+ result = [
15
+ (
16
+ dict(row)
17
+ if hasattr(row, "keys")
18
+ else dict(zip([desc[0] for desc in cursor.description], row))
19
+ )
20
+ for row in rows
21
+ ]
22
+ else:
23
+ result = []
24
+
25
+ conn.commit()
26
+ return result
27
+
28
+ finally:
29
+ close_connection(conn)
@@ -0,0 +1,51 @@
1
+ from natquery.config.connection import get_connection, close_connection
2
+
3
+
4
+ def run_explain(query: str):
5
+ """
6
+ Runs EXPLAIN (FORMAT JSON) on a query.
7
+ Returns the plan as JSON.
8
+ """
9
+ conn = get_connection()
10
+
11
+ try:
12
+ cursor = conn.cursor()
13
+
14
+ explain_query = f"EXPLAIN (FORMAT JSON) {query}"
15
+ cursor.execute(explain_query)
16
+
17
+ result = cursor.fetchone()[0] # JSON result
18
+
19
+ # PostgreSQL returns list with single JSON object
20
+ if isinstance(result, list):
21
+ return result[0]
22
+
23
+ return result
24
+
25
+ finally:
26
+ close_connection(conn)
27
+
28
+
29
+ def run_explain_analyze(query: str):
30
+ """
31
+ Runs EXPLAIN ANALYZE (FORMAT JSON) for actual execution metrics.
32
+ Returns the plan with actual execution stats as JSON.
33
+ """
34
+ conn = get_connection()
35
+
36
+ try:
37
+ cursor = conn.cursor()
38
+
39
+ explain_query = f"EXPLAIN (ANALYZE, FORMAT JSON) {query}"
40
+ cursor.execute(explain_query)
41
+
42
+ result = cursor.fetchone()[0] # JSON result
43
+
44
+ # PostgreSQL returns list with single JSON object
45
+ if isinstance(result, list):
46
+ return result[0]
47
+
48
+ return result
49
+
50
+ finally:
51
+ close_connection(conn)
File without changes
natquery/llm/client.py ADDED
@@ -0,0 +1,37 @@
1
+ from groq import Groq
2
+ from natquery.config.settings import Settings
3
+ from natquery.schema.formatter import format_schema
4
+ from natquery.prompt.builder import build_prompt
5
+ from pathlib import Path
6
+ import json
7
+
8
+
9
+ def generate_sql(user_query: str) -> str:
10
+
11
+ llm_config = Settings.get_llm_config()
12
+ client = Groq(api_key=llm_config["api_key"])
13
+
14
+ db_config = Settings.get_db_config()
15
+ db_name = db_config["dbname"]
16
+
17
+ schema_path = Path.home() / ".natquery" / db_name / "schema.json"
18
+
19
+ if schema_path.exists():
20
+ with open(schema_path, "r") as f:
21
+ schema = json.load(f)
22
+ else:
23
+ schema = {}
24
+
25
+ schema_text = format_schema(schema)
26
+ messages = build_prompt(user_query, schema_text)
27
+
28
+ response = client.chat.completions.create(
29
+ model=llm_config["model"],
30
+ messages=messages,
31
+ temperature=0.0,
32
+ )
33
+
34
+ sql = response.choices[0].message.content.strip()
35
+ sql = sql.replace("```sql", "").replace("```", "").strip()
36
+
37
+ return sql
@@ -0,0 +1,84 @@
1
+ from groq import Groq
2
+ from natquery.config.settings import Settings
3
+ from natquery.schema.formatter import format_schema
4
+ from natquery.orchestration.error_classifier import classify_error
5
+ from pathlib import Path
6
+ import json
7
+
8
+
9
+ def correct_sql(user_query: str, failed_sql: str, error_message: str) -> str:
10
+
11
+ llm_config = Settings.get_llm_config()
12
+ client = Groq(api_key=llm_config["api_key"])
13
+
14
+ db_config = Settings.get_db_config()
15
+ db_name = db_config["dbname"]
16
+
17
+ schema_path = Path.home() / ".natquery" / db_name / "schema.json"
18
+
19
+ if schema_path.exists():
20
+ with open(schema_path, "r") as f:
21
+ schema = json.load(f)
22
+ else:
23
+ schema = {}
24
+
25
+ schema_text = format_schema(schema)
26
+
27
+ error_type = classify_error(error_message)
28
+
29
+ # Dynamic instruction based on error type
30
+ correction_hint = {
31
+ "SYNTAX_ERROR": "Fix SQL syntax.",
32
+ "UNDEFINED_TABLE": "Use only valid table names from schema.",
33
+ "UNDEFINED_COLUMN": "Fix incorrect column names using schema.",
34
+ "AMBIGUOUS_COLUMN": "Disambiguate columns using table aliases.",
35
+ "TYPE_ERROR": "Fix type mismatches and operators.",
36
+ "GROUP_BY_ERROR": "Ensure proper GROUP BY usage.",
37
+ "JOIN_ERROR": "Fix joins using foreign key relationships.",
38
+ "UNKNOWN": "Fix the SQL query to make it executable.",
39
+ }[error_type]
40
+
41
+ system_prompt = f"""
42
+ You are a PostgreSQL expert specializing in fixing SQL queries.
43
+
44
+ ERROR TYPE: {error_type}
45
+
46
+ INSTRUCTION:
47
+ {correction_hint}
48
+
49
+ RULES:
50
+ - Output ONLY corrected SQL.
51
+ - Do NOT explain anything.
52
+ - Use only schema provided.
53
+ - Ensure query executes correctly.
54
+
55
+ DATABASE SCHEMA:
56
+ {schema_text}
57
+ """
58
+
59
+ user_prompt = f"""
60
+ User Query:
61
+ {user_query}
62
+
63
+ Previous SQL:
64
+ {failed_sql}
65
+
66
+ Error:
67
+ {error_message}
68
+
69
+ Return corrected SQL:
70
+ """
71
+
72
+ response = client.chat.completions.create(
73
+ model=llm_config["model"],
74
+ messages=[
75
+ {"role": "system", "content": system_prompt.strip()},
76
+ {"role": "user", "content": user_prompt.strip()},
77
+ ],
78
+ temperature=0.0,
79
+ )
80
+
81
+ sql = response.choices[0].message.content.strip()
82
+ sql = sql.replace("```sql", "").replace("```", "").strip()
83
+
84
+ return sql
File without changes
File without changes
@@ -0,0 +1,79 @@
1
+ import re
2
+
3
+
4
+ def extract_plan_root(plan_json):
5
+ if isinstance(plan_json, list):
6
+ plan_json = plan_json[0]
7
+ return plan_json
8
+
9
+
10
+ def extract_summary(plan_json):
11
+ plan_json = extract_plan_root(plan_json)
12
+
13
+ root = plan_json.get("Plan", {})
14
+
15
+ return {
16
+ "execution_time_ms": plan_json.get("Execution Time"),
17
+ "planning_time_ms": plan_json.get("Planning Time"),
18
+ "total_cost": root.get("Total Cost"),
19
+ "plan_rows": root.get("Plan Rows"),
20
+ }
21
+
22
+
23
+ def extract_columns_from_condition(condition: str):
24
+ """
25
+ Extract column names from filter/index conditions.
26
+ Example:
27
+ "((email = 'x') AND (status = 'active'))"
28
+ """
29
+
30
+ if not condition:
31
+ return []
32
+
33
+ # Simple heuristic (robust enough for now)
34
+ matches = re.findall(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*(=|>|<|>=|<=)", condition)
35
+
36
+ return list(set([m[0] for m in matches]))
37
+
38
+
39
+ def flatten_plan(node, nodes=None):
40
+ if nodes is None:
41
+ nodes = []
42
+
43
+ filter_cond = node.get("Filter")
44
+ index_cond = node.get("Index Cond")
45
+
46
+ extracted_columns = []
47
+ extracted_columns += extract_columns_from_condition(filter_cond)
48
+ extracted_columns += extract_columns_from_condition(index_cond)
49
+
50
+ node_info = {
51
+ "node_type": node.get("Node Type"),
52
+ "relation": node.get("Relation Name"),
53
+ "startup_cost": node.get("Startup Cost"),
54
+ "total_cost": node.get("Total Cost"),
55
+ "plan_rows": node.get("Plan Rows"),
56
+ "actual_rows": node.get("Actual Rows"),
57
+ "actual_time": node.get("Actual Total Time"),
58
+ "filter": filter_cond,
59
+ "index_cond": index_cond,
60
+ "columns": extracted_columns,
61
+ "join_type": node.get("Join Type"),
62
+ }
63
+
64
+ nodes.append(node_info)
65
+
66
+ for child in node.get("Plans", []) or []:
67
+ flatten_plan(child, nodes)
68
+
69
+ return nodes
70
+
71
+
72
+ def analyze_cost(plan_json):
73
+ plan_json = extract_plan_root(plan_json)
74
+ root = plan_json.get("Plan", {})
75
+
76
+ return {
77
+ "summary": extract_summary(plan_json),
78
+ "nodes": flatten_plan(root),
79
+ }
@@ -0,0 +1,58 @@
1
+ def suggest_indexes(plan_analysis):
2
+ nodes = plan_analysis.get("nodes", [])
3
+ suggestions = []
4
+
5
+ for node in nodes:
6
+ node_type = node.get("node_type")
7
+ table = node.get("relation")
8
+ columns = node.get("columns", [])
9
+
10
+ # SEQ SCAN (main case)
11
+ if node_type == "Seq Scan" and table:
12
+
13
+ if columns:
14
+ if len(columns) == 1:
15
+ col = columns[0]
16
+
17
+ suggestions.append(
18
+ {
19
+ "type": "index",
20
+ "table": table,
21
+ "columns": columns,
22
+ "sql": f"CREATE INDEX idx_{table}_{col} ON {table}({col});",
23
+ "reason": "Sequential scan with filter condition",
24
+ }
25
+ )
26
+
27
+ else:
28
+ cols = ", ".join(columns)
29
+
30
+ suggestions.append(
31
+ {
32
+ "type": "composite_index",
33
+ "table": table,
34
+ "columns": columns,
35
+ "sql": f"CREATE INDEX idx_{table}_{'_'.join(columns)} ON {table}({cols});",
36
+ "reason": "Multiple filter conditions detected",
37
+ }
38
+ )
39
+
40
+ else:
41
+ suggestions.append(
42
+ {
43
+ "type": "seq_scan_warning",
44
+ "table": table,
45
+ "reason": "Full table scan detected (no filter)",
46
+ }
47
+ )
48
+
49
+ # Join optimization
50
+ if node.get("join_type"):
51
+ suggestions.append(
52
+ {
53
+ "type": "join",
54
+ "reason": "Ensure indexes exist on join keys",
55
+ }
56
+ )
57
+
58
+ return suggestions
@@ -0,0 +1,109 @@
1
+ import json
2
+ import uuid
3
+ import hashlib
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ class NatQueryLogger:
9
+
10
+ BASE_DIR = Path.home() / ".natquery"
11
+ SYSTEM_LOG = BASE_DIR / "system.log"
12
+
13
+ @staticmethod
14
+ def _timestamp():
15
+ return datetime.now(timezone.utc).isoformat() + "Z"
16
+
17
+ @classmethod
18
+ def generate_conv_id(cls) -> str:
19
+ return str(uuid.uuid4())
20
+
21
+ @classmethod
22
+ def log_event(
23
+ cls,
24
+ level: str,
25
+ event: str,
26
+ db_name: str = None,
27
+ conv_id: str = None,
28
+ details: dict = None,
29
+ ):
30
+
31
+ cls.BASE_DIR.mkdir(exist_ok=True)
32
+
33
+ log_entry = {
34
+ "timestamp": cls._timestamp(),
35
+ "level": level,
36
+ "event": event,
37
+ "conv_id": conv_id,
38
+ "db_name": db_name,
39
+ "details": details or {},
40
+ }
41
+
42
+ with open(cls.SYSTEM_LOG, "a") as f:
43
+ f.write(json.dumps(log_entry) + "\n")
44
+
45
+ @classmethod
46
+ def log_conversation(
47
+ cls,
48
+ db_name: str,
49
+ conv_id: str,
50
+ user_query: str,
51
+ generated_sql: str,
52
+ rows_returned: int,
53
+ execution_time_ms: float,
54
+ ):
55
+
56
+ db_log_dir = cls.BASE_DIR / db_name / "logs"
57
+ db_log_dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ conv_file = db_log_dir / "conversations.jsonl"
60
+
61
+ entry = {
62
+ "timestamp": cls._timestamp(),
63
+ "conv_id": conv_id,
64
+ "user_query": user_query,
65
+ "generated_sql": generated_sql,
66
+ "rows_returned": rows_returned,
67
+ "execution_time_ms": execution_time_ms,
68
+ }
69
+
70
+ with open(conv_file, "a") as f:
71
+ f.write(json.dumps(entry) + "\n")
72
+
73
+ @staticmethod
74
+ def _hash_query(sql: str) -> str:
75
+ return hashlib.sha256(sql.encode()).hexdigest()[:16]
76
+
77
+ @classmethod
78
+ def log_performance(
79
+ cls,
80
+ db_name: str,
81
+ conv_id: str,
82
+ sql: str,
83
+ summary: dict,
84
+ suggestions: list,
85
+ ):
86
+ """
87
+ Stores structured performance history.
88
+ """
89
+
90
+ perf_dir = cls.BASE_DIR / db_name / "logs"
91
+ perf_dir.mkdir(parents=True, exist_ok=True)
92
+
93
+ perf_file = perf_dir / "performance.jsonl"
94
+
95
+ query_hash = cls._hash_query(sql)
96
+
97
+ entry = {
98
+ "timestamp": cls._timestamp(),
99
+ "conv_id": conv_id,
100
+ "query_hash": query_hash,
101
+ "sql": sql,
102
+ "execution_time_ms": summary.get("execution_time_ms"),
103
+ "total_cost": summary.get("total_cost"),
104
+ "planning_time_ms": summary.get("planning_time_ms"),
105
+ "suggestions": suggestions,
106
+ }
107
+
108
+ with open(perf_file, "a") as f:
109
+ f.write(json.dumps(entry) + "\n")
@@ -0,0 +1,58 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ BASE_DIR = Path.home() / ".natquery"
6
+
7
+
8
+ def load_performance(db_name: str):
9
+ perf_file = BASE_DIR / db_name / "logs" / "performance.jsonl"
10
+
11
+ if not perf_file.exists():
12
+ return []
13
+
14
+ entries = []
15
+ with open(perf_file, "r") as f:
16
+ for line in f:
17
+ entries.append(json.loads(line))
18
+
19
+ return entries
20
+
21
+
22
+ def get_last_run(db_name: str):
23
+ data = load_performance(db_name)
24
+ return data[-1] if data else None
25
+
26
+
27
+ def get_query_history(db_name: str, query_hash: str):
28
+ data = load_performance(db_name)
29
+ return [d for d in data if d["query_hash"] == query_hash]
30
+
31
+
32
+ def compare_last_two_runs(db_name: str):
33
+ data = load_performance(db_name)
34
+
35
+ if len(data) < 2:
36
+ return None
37
+
38
+ last = data[-1]
39
+ prev = data[-2]
40
+
41
+ return {
42
+ "execution_time_diff": last["execution_time_ms"] - prev["execution_time_ms"],
43
+ "cost_diff": last["total_cost"] - prev["total_cost"],
44
+ "last": last,
45
+ "previous": prev,
46
+ }
47
+
48
+
49
+ def get_slowest_queries(db_name: str, top_k=5):
50
+ data = load_performance(db_name)
51
+
52
+ sorted_data = sorted(
53
+ data,
54
+ key=lambda x: x.get("execution_time_ms", 0),
55
+ reverse=True,
56
+ )
57
+
58
+ return sorted_data[:top_k]
File without changes
@@ -0,0 +1,29 @@
1
+ def classify_error(error_message: str) -> str:
2
+ """
3
+ Classify PostgreSQL errors into categories.
4
+ """
5
+
6
+ msg = error_message.lower()
7
+
8
+ if "syntax error" in msg:
9
+ return "SYNTAX_ERROR"
10
+
11
+ if "does not exist" in msg:
12
+ if "column" in msg:
13
+ return "UNDEFINED_COLUMN"
14
+ if "relation" in msg or "table" in msg:
15
+ return "UNDEFINED_TABLE"
16
+
17
+ if "ambiguous" in msg:
18
+ return "AMBIGUOUS_COLUMN"
19
+
20
+ if "operator does not exist" in msg or "type" in msg:
21
+ return "TYPE_ERROR"
22
+
23
+ if "must appear in the group by clause" in msg:
24
+ return "GROUP_BY_ERROR"
25
+
26
+ if "missing from clause" in msg:
27
+ return "JOIN_ERROR"
28
+
29
+ return "UNKNOWN"