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])