Spaces:
Sleeping
Sleeping
File size: 11,551 Bytes
8edee29 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | """
executors/sql_executor.py
--------------------------
SQL code executor for AutoDevAgent.
Runs generated SQL queries against an in-memory SQLite instance.
Before executing the query, it sets up the schema (CREATE TABLE) and
seeds realistic dummy data (INSERT INTO) that was inferred by the
Planning Agent.
Design decisions:
- Uses Python's built-in sqlite3 β zero cost, no external deps.
- Everything is in-memory (:memory:) β no files created, no cleanup
needed, and each run starts with a fresh database.
- The SQLite connection is created INSIDE the worker thread to satisfy
SQLite's check_same_thread requirement β connection, schema setup,
and query execution all happen in the same thread.
- Results are formatted as a readable ASCII table for the UI and for
the debug agent to reason about.
- Timeout is enforced via thread.join(timeout) β if the thread is still
alive after the timeout, we report a timeout error.
Known limitation:
- Dummy data is LLM-generated and realistic but not exhaustive.
Edge cases like NULL-heavy data, empty tables, or duplicate keys
are not guaranteed. Documented in README.
Usage:
from executors.sql_executor import SQLExecutor
from pipeline.state import PipelineState, Language
executor = SQLExecutor()
# state.sql_schema and state.generated_code must be set
result = executor.run(state)
print(result["execution_result"].stdout) # ASCII table of results
print(result["execution_result"].success) # True / False
"""
import logging
import sqlite3
import threading
import time
from typing import Any
from config import settings
from pipeline.state import (
ExecutionResult,
PipelineState,
PipelineStatus,
SQLSchema,
)
logger = logging.getLogger(__name__)
class SQLExecutor:
"""
Executes SQL queries against a fresh in-memory SQLite instance.
Each run() call spawns a worker thread that creates its own
:memory: database, loads the schema and dummy data, then runs
the generated SQL query. The connection never crosses thread
boundaries, satisfying SQLite thread-safety requirements.
Attributes:
timeout: Maximum seconds allowed for the full query run.
"""
def __init__(self) -> None:
"""Initialise with timeout from central config."""
self.timeout: int = settings.sql_execution_timeout
def run(self, state: PipelineState) -> dict[str, Any]:
"""
Set up schema, seed dummy data, and execute the SQL query.
Args:
state: Current PipelineState. Reads: generated_code,
sql_schema.
Returns:
Partial state dict with keys:
- "execution_result": ExecutionResult
- "status": PipelineStatus.EXECUTING
"""
query = state.generated_code
if not query or not query.strip():
logger.warning("SQLExecutor received empty query.")
return {
"execution_result": ExecutionResult(
success=False,
error_msg="No SQL query was provided to execute.",
),
"status": PipelineStatus.EXECUTING,
}
logger.info("SQLExecutor starting β query length: %d chars", len(query))
result = self._run_in_thread(query, state.sql_schema)
return {
"execution_result": result,
"status": PipelineStatus.EXECUTING,
}
# ---------------------------------------------------------------- #
# Thread runner #
# ---------------------------------------------------------------- #
def _run_in_thread(
self,
query: str,
schema: "SQLSchema | None",
) -> "ExecutionResult":
"""
Run the full SQL pipeline inside a single worker thread.
Creating the SQLite connection inside the thread (rather than
in the caller and passing it across) avoids the
"SQLite objects created in a thread can only be used in that
same thread" error that occurs with check_same_thread=True.
Args:
query: SQL query string to execute.
schema: Optional SQLSchema for table setup and dummy data.
Returns:
ExecutionResult populated by the worker thread.
"""
result_holder: list = []
start = time.perf_counter()
def worker() -> None:
"""
Create connection, set up schema, run query β all in one thread.
Appends a single ExecutionResult to result_holder when done.
"""
# ββ Create fresh in-memory DB ββββββββββββββββββββββββ #
try:
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
except Exception as e:
result_holder.append(ExecutionResult(
success=False,
error_msg=f"Failed to create SQLite connection: {e}",
))
return
try:
# ββ Schema setup βββββββββββββββββββββββββββββββββ #
schema_error = _setup_schema(conn, schema)
if schema_error:
result_holder.append(ExecutionResult(
success=False,
error_msg=f"Schema setup failed: {schema_error}",
))
return
# ββ Execute query ββββββββββββββββββββββββββββββββ #
try:
cursor = conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
columns = [d[0] for d in cursor.description] if cursor.description else []
elapsed = round(time.perf_counter() - start, 3)
result_holder.append(ExecutionResult(
success=True,
stdout=_format_results(rows, columns),
exec_time=elapsed,
))
except sqlite3.Error as e:
elapsed = round(time.perf_counter() - start, 3)
result_holder.append(ExecutionResult(
success=False,
error_msg=str(e),
exec_time=elapsed,
))
finally:
conn.close()
thread = threading.Thread(target=worker, daemon=True)
thread.start()
thread.join(timeout=self.timeout)
elapsed = round(time.perf_counter() - start, 3)
# ββ Timeout path ββββββββββββββββββββββββββββββββββββββββ #
if thread.is_alive():
thread.join(timeout=1)
msg = (
f"SQL query timed out after {self.timeout} seconds. "
"The query may be too complex or missing an index."
)
logger.warning("SQLExecutor timeout after %.3fs", elapsed)
return ExecutionResult(
success=False,
error_msg=msg,
exec_time=elapsed,
)
# ββ No result produced ββββββββββββββββββββββββββββββββββ #
if not result_holder:
return ExecutionResult(
success=False,
error_msg="SQLExecutor worker produced no result.",
exec_time=elapsed,
)
exec_result = result_holder[0]
if exec_result.success:
logger.info(
"SQLExecutor success in %.3fs", exec_result.exec_time
)
else:
logger.info(
"SQLExecutor failed in %.3fs: %s",
elapsed,
exec_result.error_msg[:120],
)
return exec_result
# ------------------------------------------------------------------ #
# Module-level helpers #
# ------------------------------------------------------------------ #
def _setup_schema(
conn: sqlite3.Connection,
schema: "SQLSchema | None",
) -> "str | None":
"""
Execute CREATE TABLE and INSERT INTO statements on the connection.
Called from inside the worker thread, so conn is safe to use.
Args:
conn: Open SQLite connection.
schema: SQLSchema from the Planning Agent, or None.
Returns:
Error message string if setup failed, else None.
"""
if not schema:
# No schema provided β query runs against an empty DB.
# Fine for expressions like SELECT 1 or SELECT 42 AS answer.
logger.debug("SQLExecutor: no schema provided, running against empty DB")
return None
cursor = conn.cursor()
# Execute CREATE TABLE statements
for stmt in schema.create_statements:
try:
cursor.execute(stmt)
logger.debug("SQLExecutor: CREATE executed: %s", stmt[:80])
except sqlite3.Error as e:
return f"CREATE TABLE failed: {e}\nStatement: {stmt}"
# Execute INSERT INTO statements to seed dummy data
for stmt in schema.insert_statements:
try:
cursor.execute(stmt)
except sqlite3.Error as e:
return f"INSERT failed: {e}\nStatement: {stmt}"
conn.commit()
logger.info(
"SQLExecutor: schema ready β %d table(s), %d row(s) inserted",
len(schema.create_statements),
len(schema.insert_statements),
)
return None
def _format_results(
rows: list,
columns: list,
) -> str:
"""
Format SQLite query results as a readable ASCII table.
The output is shown in the UI and passed to the debug agent so it
can reason about whether the results are logically correct.
Args:
rows: List of sqlite3.Row objects from fetchall().
columns: List of column name strings.
Returns:
Multi-line string with headers, separator, and data rows.
Returns a descriptive message for empty result sets.
Example output:
name | salary
------|-------
Carol | 110000.0
Eve | 105000.0
Alice | 95000.0
(3 rows)
"""
if not columns:
return "(Query executed successfully β no columns returned)"
if not rows:
return f"({', '.join(columns)})\n(0 rows)"
# Convert each row to a list of strings for width calculation
str_rows = [[str(row[col]) for col in columns] for row in rows]
# Column widths = max of header length and all cell value lengths
col_widths = [
max(len(col), max(len(r[i]) for r in str_rows))
for i, col in enumerate(columns)
]
# Build header, separator, and data rows
header = " | ".join(col.ljust(col_widths[i]) for i, col in enumerate(columns))
separator = "-+-".join("-" * w for w in col_widths)
data_rows = [
" | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row))
for row in str_rows
]
count_label = f"({len(rows)} row)" if len(rows) == 1 else f"({len(rows)} rows)"
return "\n".join([header, separator, *data_rows, count_label])
|