dbagent-cli 0.1.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.
- dbagent/__init__.py +5 -0
- dbagent/agent/generator.py +264 -0
- dbagent/agent/pipeline.py +259 -0
- dbagent/agent/validator.py +116 -0
- dbagent/cli.py +717 -0
- dbagent/config.py +89 -0
- dbagent/connectors/base.py +75 -0
- dbagent/connectors/factory.py +25 -0
- dbagent/connectors/mongo.py +237 -0
- dbagent/connectors/relational.py +472 -0
- dbagent/llm/auto_setup.py +269 -0
- dbagent/llm/base.py +37 -0
- dbagent/llm/factory.py +73 -0
- dbagent/llm/gemini_provider.py +110 -0
- dbagent/llm/groq_provider.py +99 -0
- dbagent/llm/mock_provider.py +42 -0
- dbagent/llm/ollama_provider.py +145 -0
- dbagent/llm/openrouter_provider.py +100 -0
- dbagent/schema/formatter.py +123 -0
- dbagent/schema/models.py +72 -0
- dbagent/schema/selector.py +70 -0
- dbagent/ui/console.py +77 -0
- dbagent/ui/viewer.py +93 -0
- dbagent_cli-0.1.0.dist-info/METADATA +242 -0
- dbagent_cli-0.1.0.dist-info/RECORD +28 -0
- dbagent_cli-0.1.0.dist-info/WHEEL +5 -0
- dbagent_cli-0.1.0.dist-info/entry_points.txt +3 -0
- dbagent_cli-0.1.0.dist-info/top_level.txt +1 -0
dbagent/__init__.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Script Generator Engine.
|
|
3
|
+
Constructs dialect-aware, schema-informed prompts and generates SQL, migrations, ETL, and backend code.
|
|
4
|
+
Includes a fast SQL-only mode for the auto-execute pipeline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional, Callable, Dict, Any, List
|
|
9
|
+
from dbagent.schema.models import DatabaseSchema
|
|
10
|
+
from dbagent.schema.formatter import SchemaFormatter
|
|
11
|
+
from dbagent.schema.selector import SchemaSelector
|
|
12
|
+
from dbagent.llm.base import BaseLLMProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# --- Full explanatory prompt (used by `generate` command) ---
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT_TEMPLATE = """You are DB-Agent, an elite Database Engineer, SQL Architect, and Full-Stack Developer.
|
|
18
|
+
Your goal is to inspect the user's database schema and generate accurate, high-performance, dialect-specific scripts, queries, migrations, or backend APIs based on the user's instructions.
|
|
19
|
+
|
|
20
|
+
### Target Database Metadata:
|
|
21
|
+
- **Dialect**: {dialect_name}
|
|
22
|
+
- **Database Name**: {database_name}
|
|
23
|
+
|
|
24
|
+
### Database Schema Context:
|
|
25
|
+
{schema_context}
|
|
26
|
+
|
|
27
|
+
### Core Rules:
|
|
28
|
+
1. Always write queries/scripts precisely tailored for the target SQL dialect (`{dialect_name}`). Use native dialect features (e.g., PostgreSQL JSONB/CTEs/Window functions, MySQL DATE_FORMAT, SQLite strftime, etc.).
|
|
29
|
+
2. Always respect table relationships, primary keys, foreign keys, and column data types defined in the schema above.
|
|
30
|
+
3. When writing SQL queries, format them cleanly with uppercase keywords and descriptive aliases.
|
|
31
|
+
4. When writing Python ETL or API code, use modern best practices (type annotations, SQLAlchemy 2.0 / Pydantic v2 / FastAPI / Pandas).
|
|
32
|
+
5. Always enclose the primary executable script/query in a markdown code block with appropriate language tag (```sql, ```python, ```bash, etc.).
|
|
33
|
+
6. Provide a concise explanation of how the script works and any performance or indexing notes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Fast SQL-only prompt (used by pipeline `ask` command) ---
|
|
38
|
+
|
|
39
|
+
FAST_SQL_SYSTEM_PROMPT = """You are DB-Agent, an expert SQL engineer.
|
|
40
|
+
Generate ONLY the raw executable SQL query for the given database. No markdown fences, no explanations, no comments — just the pure SQL statement.
|
|
41
|
+
|
|
42
|
+
### Target Database:
|
|
43
|
+
- **Dialect**: {dialect_name}
|
|
44
|
+
- **Database**: {database_name}
|
|
45
|
+
|
|
46
|
+
### Schema:
|
|
47
|
+
{schema_context}
|
|
48
|
+
|
|
49
|
+
### Rules:
|
|
50
|
+
1. Output ONLY the SQL query — nothing else. No ```sql fences, no explanations.
|
|
51
|
+
2. Use the exact table and column names from the schema above.
|
|
52
|
+
3. Use dialect-specific syntax for `{dialect_name}`.
|
|
53
|
+
4. Write clean, optimized SQL with uppercase keywords.
|
|
54
|
+
5. If the user asks for recent/time-based data, use the dialect's native datetime functions.
|
|
55
|
+
6. Always include appropriate WHERE clauses, JOINs, and ORDER BY as needed.
|
|
56
|
+
7. Default to LIMIT 50 unless the user specifies a count.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Retry prompt when SQL fails ---
|
|
61
|
+
|
|
62
|
+
RETRY_PROMPT_TEMPLATE = """The previous SQL query failed with this error:
|
|
63
|
+
```
|
|
64
|
+
{error_message}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The failed query was:
|
|
68
|
+
```sql
|
|
69
|
+
{failed_sql}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Generate a corrected SQL query that fixes this error. Output ONLY the raw SQL — no markdown, no explanation.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScriptGenerator:
|
|
77
|
+
"""Core AI agent for generating code and scripts from database schema."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, llm_provider: BaseLLMProvider):
|
|
80
|
+
self.llm = llm_provider
|
|
81
|
+
|
|
82
|
+
# --- User intent classification (from natural language prompt) ---
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def classify_intent(user_prompt: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Classify user's natural language intent as 'read', 'write', or 'ddl'.
|
|
88
|
+
Used before SQL generation to predict the query type.
|
|
89
|
+
"""
|
|
90
|
+
prompt_lower = user_prompt.lower().strip()
|
|
91
|
+
|
|
92
|
+
# DDL indicators
|
|
93
|
+
ddl_keywords = [
|
|
94
|
+
"create table", "alter table", "drop table", "add column",
|
|
95
|
+
"remove column", "rename column", "rename table", "modify column",
|
|
96
|
+
"create index", "drop index", "truncate", "migration",
|
|
97
|
+
]
|
|
98
|
+
for kw in ddl_keywords:
|
|
99
|
+
if kw in prompt_lower:
|
|
100
|
+
return "ddl"
|
|
101
|
+
|
|
102
|
+
# Write indicators
|
|
103
|
+
write_keywords = [
|
|
104
|
+
"insert", "add record", "add row", "add new",
|
|
105
|
+
"update", "change", "modify", "set ",
|
|
106
|
+
"delete", "remove", "drop record",
|
|
107
|
+
"increment", "decrement",
|
|
108
|
+
]
|
|
109
|
+
for kw in write_keywords:
|
|
110
|
+
if kw in prompt_lower:
|
|
111
|
+
return "write"
|
|
112
|
+
|
|
113
|
+
# Default: read
|
|
114
|
+
return "read"
|
|
115
|
+
|
|
116
|
+
# --- Full generation (for `generate` command) ---
|
|
117
|
+
|
|
118
|
+
def generate_script(
|
|
119
|
+
self,
|
|
120
|
+
schema: DatabaseSchema,
|
|
121
|
+
user_prompt: str,
|
|
122
|
+
script_type: str = "auto",
|
|
123
|
+
model: Optional[str] = None,
|
|
124
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Generate dialect-specific script based on database schema and user prompt.
|
|
128
|
+
Returns full markdown response with explanations.
|
|
129
|
+
"""
|
|
130
|
+
# 1. Filter relevant tables to keep prompt compact and focused
|
|
131
|
+
relevant_tables = SchemaSelector.select_relevant_tables(schema, user_prompt, max_tables=20)
|
|
132
|
+
schema_markdown = SchemaFormatter.to_markdown(
|
|
133
|
+
schema,
|
|
134
|
+
selected_tables=relevant_tables,
|
|
135
|
+
include_samples=True,
|
|
136
|
+
max_sample_rows=2,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# 2. Build system prompt
|
|
140
|
+
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
|
141
|
+
dialect_name=schema.dialect_name,
|
|
142
|
+
database_name=schema.database_name,
|
|
143
|
+
schema_context=schema_markdown,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 3. Augment user prompt with intent specifics if needed
|
|
147
|
+
augmented_prompt = user_prompt
|
|
148
|
+
if script_type == "sql":
|
|
149
|
+
augmented_prompt = f"Write an optimized SQL query for {schema.dialect_name}: {user_prompt}"
|
|
150
|
+
elif script_type == "migration":
|
|
151
|
+
augmented_prompt = f"Generate database migration script (DDL / Alembic / Flyway) for {schema.dialect_name}: {user_prompt}"
|
|
152
|
+
elif script_type == "etl":
|
|
153
|
+
augmented_prompt = f"Generate a Python ETL / Data pipeline script for {schema.dialect_name}: {user_prompt}"
|
|
154
|
+
elif script_type == "api":
|
|
155
|
+
augmented_prompt = f"Generate FastAPI / Backend REST API endpoints for this schema: {user_prompt}"
|
|
156
|
+
elif script_type == "optimization":
|
|
157
|
+
augmented_prompt = f"Analyze performance and recommend indexes / query optimizations for: {user_prompt}"
|
|
158
|
+
|
|
159
|
+
# 4. Generate response
|
|
160
|
+
return self.llm.generate(
|
|
161
|
+
prompt=augmented_prompt,
|
|
162
|
+
system_prompt=system_prompt,
|
|
163
|
+
model=model,
|
|
164
|
+
stream_callback=stream_callback,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# --- Fast SQL-only generation (for pipeline `ask` command) ---
|
|
168
|
+
|
|
169
|
+
def generate_sql_only(
|
|
170
|
+
self,
|
|
171
|
+
schema: DatabaseSchema,
|
|
172
|
+
user_prompt: str,
|
|
173
|
+
model: Optional[str] = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Generate a raw executable SQL query — no markdown, no explanations.
|
|
177
|
+
Used by the auto-execute pipeline for speed.
|
|
178
|
+
"""
|
|
179
|
+
schema_compact = SchemaFormatter.to_compact(schema)
|
|
180
|
+
|
|
181
|
+
system_prompt = FAST_SQL_SYSTEM_PROMPT.format(
|
|
182
|
+
dialect_name=schema.dialect_name,
|
|
183
|
+
database_name=schema.database_name,
|
|
184
|
+
schema_context=schema_compact,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
raw_response = self.llm.generate(
|
|
188
|
+
prompt=user_prompt,
|
|
189
|
+
system_prompt=system_prompt,
|
|
190
|
+
model=model,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Strip any accidental markdown fences the LLM may have added
|
|
194
|
+
return self._strip_markdown_fences(raw_response)
|
|
195
|
+
|
|
196
|
+
def generate_retry_sql(
|
|
197
|
+
self,
|
|
198
|
+
schema: DatabaseSchema,
|
|
199
|
+
user_prompt: str,
|
|
200
|
+
failed_sql: str,
|
|
201
|
+
error_message: str,
|
|
202
|
+
model: Optional[str] = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Re-generate SQL after a failed execution, feeding the error back to the LLM.
|
|
206
|
+
"""
|
|
207
|
+
schema_compact = SchemaFormatter.to_compact(schema)
|
|
208
|
+
|
|
209
|
+
system_prompt = FAST_SQL_SYSTEM_PROMPT.format(
|
|
210
|
+
dialect_name=schema.dialect_name,
|
|
211
|
+
database_name=schema.database_name,
|
|
212
|
+
schema_context=schema_compact,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
retry_prompt = RETRY_PROMPT_TEMPLATE.format(
|
|
216
|
+
error_message=error_message,
|
|
217
|
+
failed_sql=failed_sql,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
full_prompt = f"Original request: {user_prompt}\n\n{retry_prompt}"
|
|
221
|
+
|
|
222
|
+
raw_response = self.llm.generate(
|
|
223
|
+
prompt=full_prompt,
|
|
224
|
+
system_prompt=system_prompt,
|
|
225
|
+
model=model,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return self._strip_markdown_fences(raw_response)
|
|
229
|
+
|
|
230
|
+
# --- Utilities ---
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _strip_markdown_fences(text: str) -> str:
|
|
234
|
+
"""Strip markdown code fences from LLM output to get raw SQL."""
|
|
235
|
+
text = text.strip()
|
|
236
|
+
# Remove ```sql ... ``` or ``` ... ```
|
|
237
|
+
pattern = r"```(?:\w+)?\s*\n?(.*?)```"
|
|
238
|
+
match = re.search(pattern, text, re.DOTALL)
|
|
239
|
+
if match:
|
|
240
|
+
return match.group(1).strip()
|
|
241
|
+
# Remove leading/trailing ``` if present
|
|
242
|
+
if text.startswith("```"):
|
|
243
|
+
text = re.sub(r"^```\w*\n?", "", text)
|
|
244
|
+
if text.endswith("```"):
|
|
245
|
+
text = re.sub(r"\n?```$", "", text)
|
|
246
|
+
return text.strip()
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def extract_code_block(response_text: str, preferred_lang: Optional[str] = None) -> Optional[str]:
|
|
250
|
+
"""Extract the main code block from LLM markdown response."""
|
|
251
|
+
# Find all code blocks ```lang ... ```
|
|
252
|
+
pattern = r"```(?:(\w+)\n)?(.*?)```"
|
|
253
|
+
matches = re.findall(pattern, response_text, re.DOTALL)
|
|
254
|
+
if not matches:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
if preferred_lang:
|
|
258
|
+
for lang, code in matches:
|
|
259
|
+
if lang.lower() == preferred_lang.lower():
|
|
260
|
+
return code.strip()
|
|
261
|
+
|
|
262
|
+
# Return the longest code block
|
|
263
|
+
longest = max(matches, key=lambda m: len(m[1]))
|
|
264
|
+
return longest[1].strip()
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QueryPipeline: Zero-friction auto-execute engine.
|
|
3
|
+
|
|
4
|
+
User Prompt -> Table Resolution -> Schema Fetch -> SQL Generation ->
|
|
5
|
+
Safety Check -> Auto-Execute -> Error Retry -> Results
|
|
6
|
+
|
|
7
|
+
Works like a native database CLI — ask a question, get results.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, Any, List, Optional, Callable
|
|
12
|
+
from dbagent.connectors.base import BaseConnector
|
|
13
|
+
from dbagent.agent.generator import ScriptGenerator
|
|
14
|
+
from dbagent.agent.validator import ScriptValidator
|
|
15
|
+
from dbagent.schema.models import DatabaseSchema
|
|
16
|
+
from dbagent.llm.base import BaseLLMProvider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PipelineResult:
|
|
21
|
+
"""Result of a pipeline execution."""
|
|
22
|
+
# The generated SQL
|
|
23
|
+
sql: str = ""
|
|
24
|
+
# Query results
|
|
25
|
+
columns: List[str] = field(default_factory=list)
|
|
26
|
+
rows: List[Dict[str, Any]] = field(default_factory=list)
|
|
27
|
+
# Status
|
|
28
|
+
was_executed: bool = False
|
|
29
|
+
was_auto_executed: bool = False
|
|
30
|
+
needs_confirmation: bool = False
|
|
31
|
+
# Query classification
|
|
32
|
+
query_type: str = "unknown" # read, write, ddl, unknown
|
|
33
|
+
intent: str = "read" # from natural language
|
|
34
|
+
# Safety
|
|
35
|
+
safety_warnings: List[str] = field(default_factory=list)
|
|
36
|
+
# Error handling
|
|
37
|
+
error: Optional[str] = None
|
|
38
|
+
retries: int = 0
|
|
39
|
+
# Table resolution
|
|
40
|
+
exact_tables: List[str] = field(default_factory=list)
|
|
41
|
+
fuzzy_tables: List[str] = field(default_factory=list)
|
|
42
|
+
ambiguous: bool = False
|
|
43
|
+
ambiguous_choices: List[str] = field(default_factory=list)
|
|
44
|
+
# Schema used
|
|
45
|
+
schema: Optional[DatabaseSchema] = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def success(self) -> bool:
|
|
49
|
+
return self.error is None and self.was_executed
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def has_results(self) -> bool:
|
|
53
|
+
return len(self.rows) > 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QueryPipeline:
|
|
57
|
+
"""
|
|
58
|
+
Zero-friction query pipeline.
|
|
59
|
+
Generates SQL from natural language, validates, and auto-executes.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
MAX_RETRIES = 2
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
connector: BaseConnector,
|
|
67
|
+
llm: BaseLLMProvider,
|
|
68
|
+
model: Optional[str] = None,
|
|
69
|
+
auto_execute: bool = True,
|
|
70
|
+
confirm_callback: Optional[Callable[[str, List[str]], bool]] = None,
|
|
71
|
+
choice_callback: Optional[Callable[[str, List[str]], Optional[str]]] = None,
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Args:
|
|
75
|
+
connector: Database connector
|
|
76
|
+
llm: LLM provider for SQL generation
|
|
77
|
+
model: Specific model name
|
|
78
|
+
auto_execute: Auto-execute safe read queries
|
|
79
|
+
confirm_callback: Called for write/ddl queries — fn(sql, warnings) -> bool
|
|
80
|
+
choice_callback: Called for ambiguous tables — fn(prompt, choices) -> selected_choice or None
|
|
81
|
+
"""
|
|
82
|
+
self.connector = connector
|
|
83
|
+
self.generator = ScriptGenerator(llm)
|
|
84
|
+
self.model = model
|
|
85
|
+
self.auto_execute = auto_execute
|
|
86
|
+
self.confirm_callback = confirm_callback
|
|
87
|
+
self.choice_callback = choice_callback
|
|
88
|
+
|
|
89
|
+
def run(self, user_prompt: str, force: bool = False) -> PipelineResult:
|
|
90
|
+
"""
|
|
91
|
+
Full pipeline: prompt -> SQL -> execute -> results.
|
|
92
|
+
|
|
93
|
+
Steps:
|
|
94
|
+
1. Classify user intent (read/write/ddl)
|
|
95
|
+
2. Resolve tables (exact vs fuzzy match, detect ambiguity)
|
|
96
|
+
3. Fetch targeted schema on-demand
|
|
97
|
+
4. Generate SQL via LLM
|
|
98
|
+
5. Classify generated SQL
|
|
99
|
+
6. Safety check
|
|
100
|
+
7. Auto-execute or confirm
|
|
101
|
+
8. On error: retry with error context (up to MAX_RETRIES)
|
|
102
|
+
"""
|
|
103
|
+
result = PipelineResult()
|
|
104
|
+
result.intent = ScriptGenerator.classify_intent(user_prompt)
|
|
105
|
+
|
|
106
|
+
# --- Step 1: Resolve tables ---
|
|
107
|
+
try:
|
|
108
|
+
exact, fuzzy = self.connector.resolve_tables(user_prompt)
|
|
109
|
+
result.exact_tables = exact
|
|
110
|
+
result.fuzzy_tables = fuzzy
|
|
111
|
+
except AttributeError:
|
|
112
|
+
# Connector doesn't support resolve_tables (e.g. old version)
|
|
113
|
+
exact, fuzzy = [], []
|
|
114
|
+
|
|
115
|
+
# Detect ambiguity: user said "users" but we have multiple fuzzy matches and no exact
|
|
116
|
+
if not exact and len(fuzzy) > 1:
|
|
117
|
+
result.ambiguous = True
|
|
118
|
+
result.ambiguous_choices = fuzzy
|
|
119
|
+
# Ask user to choose if callback is available
|
|
120
|
+
if self.choice_callback:
|
|
121
|
+
chosen = self.choice_callback(
|
|
122
|
+
f"Multiple tables match your query. Which table(s) did you mean?",
|
|
123
|
+
fuzzy,
|
|
124
|
+
)
|
|
125
|
+
if chosen:
|
|
126
|
+
# User chose — treat as exact match
|
|
127
|
+
exact = [chosen] if isinstance(chosen, str) else chosen
|
|
128
|
+
result.ambiguous = False
|
|
129
|
+
else:
|
|
130
|
+
# User cancelled
|
|
131
|
+
result.error = "Ambiguous table reference — please specify the exact table name."
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
# --- Step 2: Fetch targeted schema on-demand ---
|
|
135
|
+
try:
|
|
136
|
+
schema = self.connector.inspect_targeted(
|
|
137
|
+
user_prompt=user_prompt,
|
|
138
|
+
max_tables=10,
|
|
139
|
+
include_samples=False, # Skip samples for speed in pipeline
|
|
140
|
+
)
|
|
141
|
+
result.schema = schema
|
|
142
|
+
except Exception as e:
|
|
143
|
+
result.error = f"Schema introspection failed: {str(e)}"
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
# --- Step 3: Generate SQL ---
|
|
147
|
+
try:
|
|
148
|
+
sql = self.generator.generate_sql_only(
|
|
149
|
+
schema=schema,
|
|
150
|
+
user_prompt=user_prompt,
|
|
151
|
+
model=self.model,
|
|
152
|
+
)
|
|
153
|
+
result.sql = sql
|
|
154
|
+
except Exception as e:
|
|
155
|
+
result.error = f"SQL generation failed: {str(e)}"
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
if not result.sql or not result.sql.strip():
|
|
159
|
+
result.error = "LLM returned empty SQL."
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
# --- Step 4: Classify & safety check ---
|
|
163
|
+
result.query_type = ScriptValidator.classify_query(result.sql)
|
|
164
|
+
is_safe, warnings = ScriptValidator.analyze_safety(result.sql)
|
|
165
|
+
result.safety_warnings = warnings
|
|
166
|
+
|
|
167
|
+
# --- Step 5: Decide execute or confirm ---
|
|
168
|
+
should_execute = False
|
|
169
|
+
|
|
170
|
+
if force:
|
|
171
|
+
should_execute = True
|
|
172
|
+
elif self.auto_execute and result.query_type == "read" and is_safe:
|
|
173
|
+
# Safe read query — auto-execute
|
|
174
|
+
should_execute = True
|
|
175
|
+
result.was_auto_executed = True
|
|
176
|
+
elif result.query_type in ("write", "ddl") or not is_safe:
|
|
177
|
+
# Needs confirmation
|
|
178
|
+
result.needs_confirmation = True
|
|
179
|
+
if self.confirm_callback:
|
|
180
|
+
confirmed = self.confirm_callback(result.sql, warnings)
|
|
181
|
+
if confirmed:
|
|
182
|
+
should_execute = True
|
|
183
|
+
else:
|
|
184
|
+
# User declined — return SQL without executing
|
|
185
|
+
return result
|
|
186
|
+
else:
|
|
187
|
+
# No callback — just return the SQL for the caller to handle
|
|
188
|
+
return result
|
|
189
|
+
else:
|
|
190
|
+
# Unknown query type but safe — auto-execute
|
|
191
|
+
if self.auto_execute:
|
|
192
|
+
should_execute = True
|
|
193
|
+
result.was_auto_executed = True
|
|
194
|
+
|
|
195
|
+
# --- Step 6: Execute ---
|
|
196
|
+
if should_execute:
|
|
197
|
+
self._execute_with_retry(result, user_prompt, schema)
|
|
198
|
+
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def execute_confirmed(self, result: PipelineResult) -> PipelineResult:
|
|
202
|
+
"""
|
|
203
|
+
Execute a previously generated query that was awaiting confirmation.
|
|
204
|
+
Called after user confirms a write/ddl operation.
|
|
205
|
+
"""
|
|
206
|
+
if not result.sql:
|
|
207
|
+
result.error = "No SQL to execute."
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
user_prompt = "" # Not needed for direct execution
|
|
211
|
+
schema = result.schema
|
|
212
|
+
|
|
213
|
+
self._execute_with_retry(result, user_prompt, schema)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
def _execute_with_retry(
|
|
217
|
+
self,
|
|
218
|
+
result: PipelineResult,
|
|
219
|
+
user_prompt: str,
|
|
220
|
+
schema: Optional[DatabaseSchema],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Execute SQL with retry-on-error logic."""
|
|
223
|
+
current_sql = result.sql
|
|
224
|
+
|
|
225
|
+
for attempt in range(self.MAX_RETRIES + 1):
|
|
226
|
+
cols, rows, error = self.connector.execute_query(current_sql, limit=100)
|
|
227
|
+
|
|
228
|
+
if error is None:
|
|
229
|
+
# Success
|
|
230
|
+
result.sql = current_sql
|
|
231
|
+
result.columns = cols
|
|
232
|
+
result.rows = rows
|
|
233
|
+
result.was_executed = True
|
|
234
|
+
result.error = None
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# Execution failed
|
|
238
|
+
result.retries = attempt + 1
|
|
239
|
+
|
|
240
|
+
if attempt < self.MAX_RETRIES and schema is not None and user_prompt:
|
|
241
|
+
# Retry: feed error back to LLM for correction
|
|
242
|
+
try:
|
|
243
|
+
current_sql = self.generator.generate_retry_sql(
|
|
244
|
+
schema=schema,
|
|
245
|
+
user_prompt=user_prompt,
|
|
246
|
+
failed_sql=current_sql,
|
|
247
|
+
error_message=error,
|
|
248
|
+
model=self.model,
|
|
249
|
+
)
|
|
250
|
+
if current_sql and current_sql.strip():
|
|
251
|
+
result.sql = current_sql
|
|
252
|
+
continue
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# All retries exhausted
|
|
257
|
+
result.error = error
|
|
258
|
+
result.was_executed = False
|
|
259
|
+
return
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL and script safety validator with query intent classification.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Tuple, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScriptValidator:
|
|
10
|
+
"""Validates SQL and script safety before execution, and classifies query intent."""
|
|
11
|
+
|
|
12
|
+
DESTRUCTIVE_KEYWORDS = [
|
|
13
|
+
r"\bDROP\s+TABLE\b",
|
|
14
|
+
r"\bDROP\s+DATABASE\b",
|
|
15
|
+
r"\bTRUNCATE\b",
|
|
16
|
+
r"\bALTER\s+TABLE\b",
|
|
17
|
+
r"\bDELETE\s+FROM\b",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# --- Query Intent Classification ---
|
|
21
|
+
|
|
22
|
+
READ_PATTERNS = [
|
|
23
|
+
r"^\s*SELECT\b",
|
|
24
|
+
r"^\s*SHOW\b",
|
|
25
|
+
r"^\s*DESCRIBE\b",
|
|
26
|
+
r"^\s*DESC\b",
|
|
27
|
+
r"^\s*EXPLAIN\b",
|
|
28
|
+
r"^\s*PRAGMA\b",
|
|
29
|
+
r"^\s*WITH\b.*\bSELECT\b",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
WRITE_PATTERNS = [
|
|
33
|
+
r"^\s*INSERT\b",
|
|
34
|
+
r"^\s*UPDATE\b",
|
|
35
|
+
r"^\s*DELETE\b",
|
|
36
|
+
r"^\s*REPLACE\b",
|
|
37
|
+
r"^\s*MERGE\b",
|
|
38
|
+
r"^\s*UPSERT\b",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
DDL_PATTERNS = [
|
|
42
|
+
r"^\s*CREATE\b",
|
|
43
|
+
r"^\s*ALTER\b",
|
|
44
|
+
r"^\s*DROP\b",
|
|
45
|
+
r"^\s*TRUNCATE\b",
|
|
46
|
+
r"^\s*RENAME\b",
|
|
47
|
+
r"^\s*GRANT\b",
|
|
48
|
+
r"^\s*REVOKE\b",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def classify_query(cls, sql: str) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Classify a SQL query as 'read', 'write', 'ddl', or 'unknown'.
|
|
55
|
+
Used by the pipeline to decide auto-execute vs confirmation.
|
|
56
|
+
"""
|
|
57
|
+
if not sql or not sql.strip():
|
|
58
|
+
return "unknown"
|
|
59
|
+
|
|
60
|
+
# Strip leading comments and whitespace
|
|
61
|
+
cleaned = re.sub(r"--[^\n]*\n?", "", sql).strip()
|
|
62
|
+
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL).strip()
|
|
63
|
+
|
|
64
|
+
if not cleaned:
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
for pattern in cls.READ_PATTERNS:
|
|
68
|
+
if re.search(pattern, cleaned, re.IGNORECASE | re.DOTALL):
|
|
69
|
+
return "read"
|
|
70
|
+
|
|
71
|
+
for pattern in cls.DDL_PATTERNS:
|
|
72
|
+
if re.search(pattern, cleaned, re.IGNORECASE):
|
|
73
|
+
return "ddl"
|
|
74
|
+
|
|
75
|
+
for pattern in cls.WRITE_PATTERNS:
|
|
76
|
+
if re.search(pattern, cleaned, re.IGNORECASE):
|
|
77
|
+
return "write"
|
|
78
|
+
|
|
79
|
+
return "unknown"
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def analyze_safety(cls, query: str) -> Tuple[bool, List[str]]:
|
|
83
|
+
"""
|
|
84
|
+
Analyze whether a query contains destructive or modifying statements.
|
|
85
|
+
Returns (is_safe_read_only, list_of_warnings).
|
|
86
|
+
"""
|
|
87
|
+
warnings = []
|
|
88
|
+
q_upper = query.upper()
|
|
89
|
+
|
|
90
|
+
for pattern in cls.DESTRUCTIVE_KEYWORDS:
|
|
91
|
+
if re.search(pattern, q_upper):
|
|
92
|
+
clean_cmd = pattern.replace(r"\b", "").replace(r"\s+", " ")
|
|
93
|
+
warnings.append(f"Contains potentially destructive command: '{clean_cmd}'")
|
|
94
|
+
|
|
95
|
+
# Check for DELETE without WHERE
|
|
96
|
+
if "DELETE FROM" in q_upper and "WHERE" not in q_upper:
|
|
97
|
+
warnings.append("DELETE statement without WHERE clause detected (deletes entire table)!")
|
|
98
|
+
|
|
99
|
+
# Check for UPDATE without WHERE
|
|
100
|
+
if "UPDATE " in q_upper and "WHERE" not in q_upper:
|
|
101
|
+
warnings.append("UPDATE statement without WHERE clause detected (modifies all rows)!")
|
|
102
|
+
|
|
103
|
+
is_safe = len(warnings) == 0
|
|
104
|
+
return is_safe, warnings
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def is_auto_executable(cls, sql: str) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Quick check: can this query be auto-executed without user confirmation?
|
|
110
|
+
True only for pure read queries with no safety warnings.
|
|
111
|
+
"""
|
|
112
|
+
query_type = cls.classify_query(sql)
|
|
113
|
+
if query_type != "read":
|
|
114
|
+
return False
|
|
115
|
+
is_safe, _ = cls.analyze_safety(sql)
|
|
116
|
+
return is_safe
|