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.
- natquery/__init__.py +0 -0
- natquery/cli/__init__.py +0 -0
- natquery/cli/commands.py +256 -0
- natquery/cli/formatter.py +126 -0
- natquery/cli/main.py +51 -0
- natquery/cli/shell.py +135 -0
- natquery/config/__init__.py +0 -0
- natquery/config/connection.py +123 -0
- natquery/config/settings.py +100 -0
- natquery/execution/__init__.py +0 -0
- natquery/execution/engine.py +29 -0
- natquery/execution/explain.py +51 -0
- natquery/llm/__init__.py +0 -0
- natquery/llm/client.py +37 -0
- natquery/llm/self_corrector.py +84 -0
- natquery/ml/__init__.py +0 -0
- natquery/observability/__init__.py +0 -0
- natquery/observability/cost_analyzer.py +79 -0
- natquery/observability/index_recommender.py +58 -0
- natquery/observability/logger.py +109 -0
- natquery/observability/performance_api.py +58 -0
- natquery/orchestration/__init__.py +0 -0
- natquery/orchestration/error_classifier.py +29 -0
- natquery/orchestration/error_handler.py +81 -0
- natquery/orchestration/pipeline.py +192 -0
- natquery/orchestration/workspace.py +44 -0
- natquery/prompt/__init__.py +0 -0
- natquery/prompt/builder.py +27 -0
- natquery/schema/__init__.py +0 -0
- natquery/schema/extractor.py +80 -0
- natquery/schema/formatter.py +52 -0
- natquery/security/__init__.py +0 -0
- natquery/security/validator.py +20 -0
- natquery-1.0.0.dist-info/METADATA +147 -0
- natquery-1.0.0.dist-info/RECORD +39 -0
- natquery-1.0.0.dist-info/WHEEL +5 -0
- natquery-1.0.0.dist-info/entry_points.txt +2 -0
- natquery-1.0.0.dist-info/licenses/LICENSE +21 -0
- natquery-1.0.0.dist-info/top_level.txt +1 -0
natquery/__init__.py
ADDED
|
File without changes
|
natquery/cli/__init__.py
ADDED
|
File without changes
|
natquery/cli/commands.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
from natquery.config.settings import Settings
|
|
6
|
+
from natquery.observability.performance_api import (
|
|
7
|
+
get_last_run,
|
|
8
|
+
compare_last_two_runs,
|
|
9
|
+
get_slowest_queries,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BASE_DIR = Path.home() / ".natquery"
|
|
14
|
+
CONFIG_FILE = BASE_DIR / "config.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_db_name_from_dsn(dsn: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Extract database name from a PostgreSQL DSN.
|
|
20
|
+
Supports both URL format (postgresql://) and key-value format (dbname=).
|
|
21
|
+
"""
|
|
22
|
+
parsed = urlparse(dsn)
|
|
23
|
+
|
|
24
|
+
if parsed.scheme: # URL format
|
|
25
|
+
db_name = parsed.path.lstrip("/").split("?")[
|
|
26
|
+
0
|
|
27
|
+
] # Remove leading / and query params
|
|
28
|
+
return db_name if db_name else "unknown_db"
|
|
29
|
+
|
|
30
|
+
# Fallback: key-value DSN (e.g., "dbname=mydb user=postgres host=localhost")
|
|
31
|
+
parts = dsn.split()
|
|
32
|
+
for part in parts:
|
|
33
|
+
if part.startswith("dbname="):
|
|
34
|
+
return part.split("=", 1)[1]
|
|
35
|
+
|
|
36
|
+
return "unknown_db"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def connect_command(dsn_arg=None):
|
|
40
|
+
"""
|
|
41
|
+
Setup for NatQuery.
|
|
42
|
+
If dsn_arg is provided, extracts DB name and uses it as workspace.
|
|
43
|
+
Otherwise, runs interactive setup for standard or DSN connections.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
print("============ NatQuery Setup ============")
|
|
47
|
+
|
|
48
|
+
BASE_DIR.mkdir(exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# If DSN was provided as argument
|
|
51
|
+
if dsn_arg:
|
|
52
|
+
db_name = extract_db_name_from_dsn(dsn_arg)
|
|
53
|
+
workspace_name = db_name
|
|
54
|
+
|
|
55
|
+
print(f"Your Datbase Name: {db_name}")
|
|
56
|
+
print(f"Workspace Name: {workspace_name}")
|
|
57
|
+
|
|
58
|
+
config = {
|
|
59
|
+
"connection_type": "dsn",
|
|
60
|
+
"db_dsn": dsn_arg,
|
|
61
|
+
"db_name": db_name,
|
|
62
|
+
"workspace_name": workspace_name,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
else:
|
|
66
|
+
db_name = input("Database Name: ").strip()
|
|
67
|
+
workspace_name = db_name
|
|
68
|
+
|
|
69
|
+
db_dir = BASE_DIR / workspace_name
|
|
70
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
ssl_input = input("Use SSL? (yes/no): ").strip().lower()
|
|
73
|
+
use_ssl = ssl_input in {"yes", "y"}
|
|
74
|
+
|
|
75
|
+
config = {
|
|
76
|
+
"connection_type": "standard",
|
|
77
|
+
"db_host": input("DB Host (e.g. 127.0.0.1): ").strip(),
|
|
78
|
+
"db_port": input("DB Port (e.g. 5432): ").strip(),
|
|
79
|
+
"db_name": db_name,
|
|
80
|
+
"db_user": input("DB User: ").strip(),
|
|
81
|
+
"db_password": getpass("DB Password: "),
|
|
82
|
+
"workspace_name": workspace_name,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if use_ssl:
|
|
86
|
+
config["sslmode"] = "require"
|
|
87
|
+
|
|
88
|
+
db_dir = BASE_DIR / workspace_name
|
|
89
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# LLM config (common to both)
|
|
92
|
+
config.update(
|
|
93
|
+
{
|
|
94
|
+
"llm_provider": input("LLM Provider (groq): ").strip(),
|
|
95
|
+
"llm_api_key": getpass("LLM API Key: "),
|
|
96
|
+
"llm_model": input("LLM Model (e.g. llama-3.1-70b-versatile): ").strip(),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
config_file = db_dir / "config.json"
|
|
101
|
+
|
|
102
|
+
with open(config_file, "w") as f:
|
|
103
|
+
json.dump(config, f, indent=2)
|
|
104
|
+
|
|
105
|
+
# Set active database
|
|
106
|
+
(BASE_DIR / "current_db").write_text(workspace_name)
|
|
107
|
+
|
|
108
|
+
print(f"\nConfiguration saved for workspace: {workspace_name}")
|
|
109
|
+
print("You can now run: natquery\n")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_command():
|
|
113
|
+
"""
|
|
114
|
+
Deletes the active database configuration.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
config_path = Settings._get_config_path()
|
|
119
|
+
except RuntimeError:
|
|
120
|
+
print("No active database configured.")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if config_path.exists():
|
|
124
|
+
config_path.unlink()
|
|
125
|
+
print("Configuration removed for active database.")
|
|
126
|
+
else:
|
|
127
|
+
print("No configuration found.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def show_config_command():
|
|
131
|
+
"""
|
|
132
|
+
Display current active DB configuration (without secrets).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
config = Settings.load_config()
|
|
137
|
+
except RuntimeError:
|
|
138
|
+
print("NatQuery not configured.")
|
|
139
|
+
print("Run: natquery connect")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
safe_config = config.copy()
|
|
143
|
+
|
|
144
|
+
if "db_password" in safe_config:
|
|
145
|
+
safe_config["db_password"] = "********"
|
|
146
|
+
|
|
147
|
+
if "llm_api_key" in safe_config:
|
|
148
|
+
safe_config["llm_api_key"] = "********"
|
|
149
|
+
|
|
150
|
+
print(json.dumps(safe_config, indent=2))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def select_database_command():
|
|
154
|
+
"""
|
|
155
|
+
List all configured databases and allow user to select one as the active database.
|
|
156
|
+
Updates the current_db file without requiring reconnection.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
BASE_DIR.mkdir(exist_ok=True)
|
|
160
|
+
|
|
161
|
+
# Find all database directories (those containing config.json)
|
|
162
|
+
databases = []
|
|
163
|
+
|
|
164
|
+
if BASE_DIR.exists():
|
|
165
|
+
for item in BASE_DIR.iterdir():
|
|
166
|
+
if item.is_dir() and (item / "config.json").exists():
|
|
167
|
+
databases.append(item.name)
|
|
168
|
+
|
|
169
|
+
if not databases:
|
|
170
|
+
print("No configured databases found.")
|
|
171
|
+
print("Run: natquery connect")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Get current database
|
|
175
|
+
current_db_file = BASE_DIR / "current_db"
|
|
176
|
+
current_db = (
|
|
177
|
+
current_db_file.read_text().strip() if current_db_file.exists() else None
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Display databases
|
|
181
|
+
print("\n========== Configured Databases ==========")
|
|
182
|
+
for i, db in enumerate(databases, 1):
|
|
183
|
+
marker = " (current)" if db == current_db else ""
|
|
184
|
+
print(f"{i}. {db}{marker}")
|
|
185
|
+
|
|
186
|
+
print("\n")
|
|
187
|
+
|
|
188
|
+
# Get user selection
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
selection = input("Select a database (enter number): ").strip()
|
|
192
|
+
index = int(selection) - 1
|
|
193
|
+
|
|
194
|
+
if 0 <= index < len(databases):
|
|
195
|
+
selected_db = databases[index]
|
|
196
|
+
break
|
|
197
|
+
else:
|
|
198
|
+
print(
|
|
199
|
+
f"Invalid selection. Please enter a number between 1 and {len(databases)}."
|
|
200
|
+
)
|
|
201
|
+
except ValueError:
|
|
202
|
+
print("Invalid input. Please enter a number.")
|
|
203
|
+
|
|
204
|
+
# Update current database
|
|
205
|
+
current_db_file.write_text(selected_db)
|
|
206
|
+
print(f"\nActive database switched to: {selected_db}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def performance_summary_command():
|
|
210
|
+
db_name = Settings.get_db_config()["dbname"]
|
|
211
|
+
|
|
212
|
+
last = get_last_run(db_name)
|
|
213
|
+
|
|
214
|
+
if not last:
|
|
215
|
+
print("No performance data available.")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
print("\n====== Performance Summary ======")
|
|
219
|
+
print(f"Execution Time: {last['execution_time_ms']} ms")
|
|
220
|
+
print(f"Total Cost: {last['total_cost']}")
|
|
221
|
+
print(f"Planning Time: {last['planning_time_ms']} ms")
|
|
222
|
+
|
|
223
|
+
print("\n--- Suggestions ---")
|
|
224
|
+
for s in last["suggestions"]:
|
|
225
|
+
print(f"- {s}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def performance_compare_command():
|
|
229
|
+
db_name = Settings.get_db_config()["dbname"]
|
|
230
|
+
|
|
231
|
+
comp = compare_last_two_runs(db_name)
|
|
232
|
+
|
|
233
|
+
if not comp:
|
|
234
|
+
print("Oops! Not enough data to compare.")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
print("\n====== Performance Comparison ======")
|
|
238
|
+
|
|
239
|
+
print(f"Execution Time Change: {comp['execution_time_diff']} ms")
|
|
240
|
+
print(f"Cost Change: {comp['cost_diff']}")
|
|
241
|
+
|
|
242
|
+
if comp["execution_time_diff"] < 0:
|
|
243
|
+
print("Booyah! Query improved!")
|
|
244
|
+
else:
|
|
245
|
+
print("Oops! Query got slower.")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def performance_slowest_command():
|
|
249
|
+
db_name = Settings.get_db_config()["dbname"]
|
|
250
|
+
|
|
251
|
+
slow = get_slowest_queries(db_name)
|
|
252
|
+
|
|
253
|
+
print("\n====== Slowest Queries ======")
|
|
254
|
+
|
|
255
|
+
for i, q in enumerate(slow, 1):
|
|
256
|
+
print(f"{i}. {q['execution_time_ms']} ms → {q['sql']}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from rich.table import Table
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich import box
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_number(value):
|
|
11
|
+
return isinstance(value, (int, float))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _infer_alignment(data, column):
|
|
15
|
+
"""
|
|
16
|
+
Infer alignment based on column values.
|
|
17
|
+
"""
|
|
18
|
+
for row in data:
|
|
19
|
+
val = row.get(column)
|
|
20
|
+
if val is not None:
|
|
21
|
+
if _is_number(val):
|
|
22
|
+
return "right"
|
|
23
|
+
else:
|
|
24
|
+
return "left"
|
|
25
|
+
return "left"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _truncate(text, max_length=50):
|
|
29
|
+
"""
|
|
30
|
+
Soft truncate long values (for extremely wide columns).
|
|
31
|
+
"""
|
|
32
|
+
text = str(text)
|
|
33
|
+
if len(text) > max_length:
|
|
34
|
+
return text[: max_length - 3] + "..."
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_value(value):
|
|
39
|
+
"""
|
|
40
|
+
Clean formatting of cell values.
|
|
41
|
+
"""
|
|
42
|
+
if value is None:
|
|
43
|
+
return ""
|
|
44
|
+
return str(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_table(
|
|
48
|
+
data: list[dict],
|
|
49
|
+
title: str = "#### Result ####",
|
|
50
|
+
page_size: int = 20,
|
|
51
|
+
wrap: bool = True,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Advanced table printer:
|
|
55
|
+
- smart alignment
|
|
56
|
+
- pagination
|
|
57
|
+
- wrapping
|
|
58
|
+
- adaptive formatting
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
if not data:
|
|
62
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
columns = list(data[0].keys())
|
|
66
|
+
|
|
67
|
+
# Special case: single column → vertical list
|
|
68
|
+
if len(columns) == 1:
|
|
69
|
+
col = columns[0]
|
|
70
|
+
console.print(Panel(f"[bold cyan]{col}[/bold cyan]", title=title))
|
|
71
|
+
|
|
72
|
+
for row in data:
|
|
73
|
+
console.print(f"- {row.get(col)}")
|
|
74
|
+
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
total_rows = len(data)
|
|
78
|
+
total_pages = math.ceil(total_rows / page_size)
|
|
79
|
+
|
|
80
|
+
for page in range(total_pages):
|
|
81
|
+
|
|
82
|
+
start = page * page_size
|
|
83
|
+
end = start + page_size
|
|
84
|
+
chunk = data[start:end]
|
|
85
|
+
|
|
86
|
+
table = Table(
|
|
87
|
+
title=f"{title} (Page {page+1}/{total_pages})",
|
|
88
|
+
show_header=True,
|
|
89
|
+
header_style="bold magenta",
|
|
90
|
+
box=box.SIMPLE_HEAVY,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Add columns with smart alignment
|
|
94
|
+
for col in columns:
|
|
95
|
+
justify = _infer_alignment(chunk, col)
|
|
96
|
+
|
|
97
|
+
table.add_column(
|
|
98
|
+
col,
|
|
99
|
+
justify=justify,
|
|
100
|
+
overflow="fold" if wrap else "ellipsis",
|
|
101
|
+
no_wrap=not wrap,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Add rows
|
|
105
|
+
for row in chunk:
|
|
106
|
+
row_values = []
|
|
107
|
+
|
|
108
|
+
for col in columns:
|
|
109
|
+
val = _format_value(row.get(col))
|
|
110
|
+
val = _truncate(val, 80)
|
|
111
|
+
row_values.append(val)
|
|
112
|
+
|
|
113
|
+
table.add_row(*row_values)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
# Pagination pause (non-blocking for automation)
|
|
118
|
+
if page < total_pages - 1:
|
|
119
|
+
try:
|
|
120
|
+
console.print(
|
|
121
|
+
"[dim]Press Enter for next page... (or Ctrl+C to stop)[/dim]"
|
|
122
|
+
)
|
|
123
|
+
input()
|
|
124
|
+
except (EOFError, KeyboardInterrupt):
|
|
125
|
+
console.print("[yellow]Pagination stopped.[/yellow]")
|
|
126
|
+
break
|
natquery/cli/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from rich import print
|
|
3
|
+
from natquery.cli.commands import (
|
|
4
|
+
connect_command,
|
|
5
|
+
reset_command,
|
|
6
|
+
show_config_command,
|
|
7
|
+
select_database_command,
|
|
8
|
+
)
|
|
9
|
+
from natquery.cli.shell import start_shell
|
|
10
|
+
from natquery.config.settings import Settings
|
|
11
|
+
from natquery.orchestration.workspace import initialize_workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
|
|
16
|
+
if len(sys.argv) > 1:
|
|
17
|
+
|
|
18
|
+
cmd = sys.argv[1]
|
|
19
|
+
|
|
20
|
+
if cmd == "connect":
|
|
21
|
+
# Pass DSN if provided: natquery connect <dsn>
|
|
22
|
+
dsn = sys.argv[2] if len(sys.argv) > 2 else None
|
|
23
|
+
connect_command(dsn)
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
elif cmd == "reset":
|
|
27
|
+
reset_command()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
elif cmd == "config":
|
|
31
|
+
show_config_command()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
elif cmd == "select":
|
|
35
|
+
select_database_command()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
else:
|
|
39
|
+
print(f"Unknown command: {cmd}")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# No subcommand → start REPL
|
|
43
|
+
|
|
44
|
+
if not Settings.exists():
|
|
45
|
+
print("NatQuery not configured.")
|
|
46
|
+
print("Run: natquery connect")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
print("\n[bold cyan]Starting NatQuery...[/bold cyan]\n")
|
|
50
|
+
initialize_workspace()
|
|
51
|
+
start_shell()
|
natquery/cli/shell.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from rich import print
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from natquery.orchestration.pipeline import run_query
|
|
6
|
+
from natquery.llm.client import generate_sql
|
|
7
|
+
from natquery.cli.formatter import print_table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def show_banner():
|
|
13
|
+
banner = Text(
|
|
14
|
+
"""
|
|
15
|
+
███╗ ██╗ █████╗ ████████╗ ██████╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗
|
|
16
|
+
████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██║ ██║██╔════╝██╔══██╗╚██╗ ██╔╝
|
|
17
|
+
██╔██╗ ██║███████║ ██║ ██║ ██║██║ ██║█████╗ ██████╔╝ ╚████╔╝
|
|
18
|
+
██║╚██╗██║██╔══██║ ██║ ██║▄▄ ██║██║ ██║██╔══╝ ██╔══██╗ ╚██╔╝
|
|
19
|
+
██║ ╚████║██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗██║ ██║ ██║
|
|
20
|
+
╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
|
|
21
|
+
""",
|
|
22
|
+
style="bold cyan",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
print(Panel(banner, title="⚡ NatQuery CLI", border_style="green"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_plan_output(summary, suggestions):
|
|
29
|
+
"""Format execution plan and index recommendations for display."""
|
|
30
|
+
if not summary and not suggestions:
|
|
31
|
+
print("[dim]No plan data available[/dim]")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Display execution metrics
|
|
35
|
+
if summary:
|
|
36
|
+
metrics = []
|
|
37
|
+
if summary.get("execution_time_ms"):
|
|
38
|
+
metrics.append(f"Execution Time: {summary['execution_time_ms']:.2f}ms")
|
|
39
|
+
if summary.get("planning_time_ms"):
|
|
40
|
+
metrics.append(f"Planning Time: {summary['planning_time_ms']:.2f}ms")
|
|
41
|
+
if summary.get("total_cost"):
|
|
42
|
+
metrics.append(f"Total Cost: {summary['total_cost']:.2f}")
|
|
43
|
+
if summary.get("plan_rows"):
|
|
44
|
+
metrics.append(f"Planned Rows: {summary['plan_rows']}")
|
|
45
|
+
|
|
46
|
+
if metrics:
|
|
47
|
+
print(
|
|
48
|
+
Panel(
|
|
49
|
+
"\n".join(metrics),
|
|
50
|
+
title="Execution Plan",
|
|
51
|
+
border_style="yellow",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Display index recommendations
|
|
56
|
+
if suggestions and len(suggestions) > 0:
|
|
57
|
+
suggestion_lines = []
|
|
58
|
+
for i, sugg in enumerate(suggestions, 1):
|
|
59
|
+
sugg_type = sugg.get("type", "unknown")
|
|
60
|
+
reason = sugg.get("reason", "")
|
|
61
|
+
suggestion_lines.append(f"[bold]{i}. {sugg_type.upper()}[/bold]")
|
|
62
|
+
if reason:
|
|
63
|
+
suggestion_lines.append(f" Reason: {reason}")
|
|
64
|
+
if sugg.get("sql"):
|
|
65
|
+
suggestion_lines.append(f" SQL: {sugg['sql']}")
|
|
66
|
+
suggestion_lines.append("")
|
|
67
|
+
|
|
68
|
+
print(
|
|
69
|
+
Panel(
|
|
70
|
+
"\n".join(suggestion_lines),
|
|
71
|
+
title="Index Recommendations",
|
|
72
|
+
border_style="cyan",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
elif suggestions is not None and len(suggestions) == 0:
|
|
76
|
+
print("[dim]No optimization recommendations[/dim]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def start_shell():
|
|
80
|
+
show_banner()
|
|
81
|
+
show_sql = False
|
|
82
|
+
show_plan = False
|
|
83
|
+
print("[bold green]NatQuery ready.[/bold green]")
|
|
84
|
+
print(
|
|
85
|
+
"[dim]Commands: /sql (toggle SQL), /plan (toggle execution plan), exit[/dim]\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
while True:
|
|
89
|
+
query = console.input("[bold blue]> [/bold blue]")
|
|
90
|
+
|
|
91
|
+
if query.lower() in ["exit", "quit"]:
|
|
92
|
+
print("[bold red]Goodbye![/bold red]")
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
# Toggle SQL display
|
|
96
|
+
if query.strip() == "/sql":
|
|
97
|
+
show_sql = not show_sql
|
|
98
|
+
status = "ON" if show_sql else "OFF"
|
|
99
|
+
print(f"[yellow]Show SQL:[/yellow] {status}")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Toggle plan display
|
|
103
|
+
if query.strip() == "/plan":
|
|
104
|
+
show_plan = not show_plan
|
|
105
|
+
status = "ON" if show_plan else "OFF"
|
|
106
|
+
print(f"[yellow]Show Plan:[/yellow] {status}")
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if not query.strip():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
query_output = run_query(query)
|
|
114
|
+
|
|
115
|
+
# Handle both old (list) and new (dict) return formats
|
|
116
|
+
if isinstance(query_output, dict):
|
|
117
|
+
result = query_output["result"]
|
|
118
|
+
summary = query_output.get("summary")
|
|
119
|
+
suggestions = query_output.get("suggestions")
|
|
120
|
+
else:
|
|
121
|
+
result = query_output
|
|
122
|
+
summary = None
|
|
123
|
+
suggestions = None
|
|
124
|
+
|
|
125
|
+
if show_sql:
|
|
126
|
+
generated_sql = generate_sql(query)
|
|
127
|
+
print(Panel(generated_sql, title="Generated SQL", border_style="cyan"))
|
|
128
|
+
|
|
129
|
+
print_table(result)
|
|
130
|
+
|
|
131
|
+
if show_plan:
|
|
132
|
+
format_plan_output(summary, suggestions)
|
|
133
|
+
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"[bold red]Error:[/bold red] {e}")
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import psycopg2
|
|
2
|
+
from psycopg2.extensions import connection as PGConnection
|
|
3
|
+
from psycopg2.extras import RealDictCursor
|
|
4
|
+
from natquery.config.settings import Settings
|
|
5
|
+
from natquery.observability.logger import NatQueryLogger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_connection() -> PGConnection:
|
|
9
|
+
"""
|
|
10
|
+
Creates and returns a PostgreSQL connection.
|
|
11
|
+
Supports:
|
|
12
|
+
- Standard host/port configuration
|
|
13
|
+
- DSN (connection string)
|
|
14
|
+
- SSL (optional)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
db_config = Settings.get_db_config()
|
|
18
|
+
|
|
19
|
+
# Determine DB name safely (for logging)
|
|
20
|
+
db_name = db_config.get("dbname", "unknown_db")
|
|
21
|
+
|
|
22
|
+
# Log connection attempt
|
|
23
|
+
NatQueryLogger.log_event(
|
|
24
|
+
level="INFO",
|
|
25
|
+
event="db_connection_attempt",
|
|
26
|
+
db_name=db_name,
|
|
27
|
+
conv_id=None,
|
|
28
|
+
details=db_config,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
|
|
33
|
+
# ---- DSN MODE ----
|
|
34
|
+
if db_config["type"] == "dsn":
|
|
35
|
+
|
|
36
|
+
conn = psycopg2.connect(
|
|
37
|
+
db_config["dsn"],
|
|
38
|
+
connect_timeout=5,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ---- STANDARD MODE ----
|
|
42
|
+
else:
|
|
43
|
+
|
|
44
|
+
connect_args = {
|
|
45
|
+
"host": db_config["host"],
|
|
46
|
+
"port": db_config["port"],
|
|
47
|
+
"dbname": db_config["dbname"],
|
|
48
|
+
"user": db_config["user"],
|
|
49
|
+
"password": db_config["password"],
|
|
50
|
+
"connect_timeout": 5,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Optional SSL
|
|
54
|
+
sslmode = db_config.get("sslmode")
|
|
55
|
+
if sslmode:
|
|
56
|
+
connect_args["sslmode"] = sslmode
|
|
57
|
+
|
|
58
|
+
conn = psycopg2.connect(**connect_args)
|
|
59
|
+
|
|
60
|
+
conn.autocommit = False
|
|
61
|
+
|
|
62
|
+
# Log success
|
|
63
|
+
NatQueryLogger.log_event(
|
|
64
|
+
level="INFO",
|
|
65
|
+
event="db_connection_success",
|
|
66
|
+
db_name=db_name,
|
|
67
|
+
conv_id=None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return conn
|
|
71
|
+
|
|
72
|
+
except psycopg2.OperationalError as e:
|
|
73
|
+
|
|
74
|
+
# Log failure
|
|
75
|
+
NatQueryLogger.log_event(
|
|
76
|
+
level="ERROR",
|
|
77
|
+
event="db_connection_failed",
|
|
78
|
+
db_name=db_name,
|
|
79
|
+
conv_id=None,
|
|
80
|
+
details={"error": str(e)},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
raise ConnectionError(
|
|
84
|
+
f"Unable to connect to PostgreSQL server.\nDetails: {e}"
|
|
85
|
+
) from e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_cursor(conn: PGConnection):
|
|
89
|
+
"""
|
|
90
|
+
Returns a dictionary-style cursor.
|
|
91
|
+
"""
|
|
92
|
+
return conn.cursor(cursor_factory=RealDictCursor)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def close_connection(conn: PGConnection) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Safely closes a PostgreSQL connection.
|
|
98
|
+
"""
|
|
99
|
+
if conn:
|
|
100
|
+
try:
|
|
101
|
+
# Safely extract db_name; use fallback if connection is partially closed
|
|
102
|
+
try:
|
|
103
|
+
db_name = conn.get_dsn_parameters().get("dbname")
|
|
104
|
+
except (AttributeError, psycopg2.Error):
|
|
105
|
+
db_name = "unknown_db"
|
|
106
|
+
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
NatQueryLogger.log_event(
|
|
110
|
+
level="INFO",
|
|
111
|
+
event="db_connection_closed",
|
|
112
|
+
db_name=db_name,
|
|
113
|
+
conv_id=None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
NatQueryLogger.log_event(
|
|
118
|
+
level="ERROR",
|
|
119
|
+
event="db_connection_close_failed",
|
|
120
|
+
db_name=None,
|
|
121
|
+
conv_id=None,
|
|
122
|
+
details={"error": str(e)},
|
|
123
|
+
)
|