LLMSDE

Plain-English questions to safe SQL, with two independent layers between the model and the data

Ask a database a question in plain English and get safe SQL, the right chart and a live dashboard

Sole developer

The problem

Teams have the numbers they need in a database, but getting them out means waiting for someone who writes SQL. Letting a model write SQL directly risks queries that change data or read what they shouldn't.

Team: Personal project. Timeline: September 2026.

Status

No measured results yet. Measure accuracy on a held-out question set.

Architecture

Architecture: NL2SQL-XReact app, FastAPI query service, TTL cache and in-flight dedupe, Gemini (JSON schema output), SQL guard (sqlglot AST), Chart and insight engine, Read-only executor (authorizer, time budget), Dataset SQLite files. Connections: React app to FastAPI query service; FastAPI query service to TTL cache and in-flight dedupe; FastAPI query service to Gemini (JSON schema output); FastAPI query service to SQL guard (sqlglot AST); SQL guard (sqlglot AST) to Read-only executor (authorizer, time budget); Read-only executor (authorizer, time budget) to Dataset SQLite files; FastAPI query service to Chart and insight engine.SSE / JSONschema + questionone read-only SELECTReact appclientFastAPI query servicegatewayTTL cache and in-flight dedupecacheGemini (JSON schema output)modelSQL guard (sqlglot AST)serviceChart and insight engineserviceRead-only executor (authorizer, time budget)serviceDataset SQLite filesdata store

Request flows

  1. A question becomes a chart: React app → FastAPI query service → Gemini (JSON schema output)
  2. Generated SQL is checked before it runs: FastAPI query service → SQL guard (sqlglot AST) → Read-only executor (authorizer, time budget) → Dataset SQLite files

Architecture: NL2SQL-X

Components

Request flows

Choose a component to focus it, or a flow to watch its requests. In the view, drag or use the arrow keys to turn it and + or − to zoom. Escape closes.

Decision 1 of 3

How should the model return its answer?

Options
Free text parsed with regexes · A JSON schema response
Chose
A JSON schema response of {answerable, sql, explanation}
Trade-off
The output is tied to one provider's structured-output support.
Evidence
The model can say a question is unanswerable instead of producing a guess, and there is no free text to parse.

Decision 2 of 3

How is generated SQL kept from changing or leaking data?

Options
Trust the prompt · A static check alone · A static AST guard and a runtime authorizer
Chose
Two independent layers, a sqlglot AST allow-list and the SQLite authorizer on a read-only connection
Trade-off
Some valid analytical SQL is refused, such as table-valued functions and schema-qualified names.
Evidence
The static guard can be wrong without data being exposed, because the authorizer separately allows only reads of the dataset's own tables.

Decision 3 of 3

What stops a runaway query?

Options
A database timeout · A progress-handler time budget and a row cap
Chose
A progress-handler time budget (3 s by default) and a row cap with a truncated flag
Trade-off
Long legitimate queries are cut off.
Evidence
Recursive CTEs and other runaway queries are interrupted inside SQLite rather than holding a worker.

The static guard on generated SQL

def check(sql: str, allowed_tables: set[str]) -> CheckedSQL:
    sql = strip_fences(sql)
    if not sql:
        raise UnsafeSQLError("The generated query is empty.")
    try:
        statements = [s for s in sqlglot.parse(sql, read="sqlite") if s is not None]
    except ParseError as exc:
        raise UnsafeSQLError(f"The generated SQL could not be parsed: {_first_line(exc)}") from exc
    if len(statements) != 1:
        raise UnsafeSQLError("Only a single statement is allowed.")
    tree = statements[0]
    if not isinstance(tree, exp.Query):
        raise UnsafeSQLError("Only read-only SELECT queries are allowed.")
    if any(isinstance(node, WRITE_NODES) for node in tree.walk()):
        raise UnsafeSQLError("Only read-only SELECT queries are allowed.")

    for fn in tree.find_all(exp.Anonymous, exp.Func):
        name = (fn.name if isinstance(fn, exp.Anonymous) else fn.sql_name()).lower()
        if name in BLOCKED_FUNCTIONS:
            raise UnsafeSQLError(f"The function {name}() is not allowed.")

The query is checked as a syntax tree, not as text, so a write hidden inside a CTE or a subquery is found wherever it sits.

Questions?

Read the full write-up