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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ DB-Agent: Universal Database Introspection and Script Generation AI Agent (CLI).
3
+ """
4
+
5
+ __version__ = "0.1.0"
@@ -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