Skip to content

Commit c8742c6

Browse files
JuliaEdomclaude
andcommitted
fix(db): run SQL on a parameterless DBAPI cursor — % broke every Postgres query
`execute_readonly` went through `exec_driver_sql`, which hands the driver an (empty) parameter set. psycopg speaks pyformat, so it then scanned the statement for placeholders and rejected `LIKE '%variance%'`: only '%s', '%b', '%t' are allowed as placeholders, got '%v' That is the single most common shape a text-to-SQL model emits, plus the `%` modulo operator — and it hit BIRD's own gold SQL too, so affected questions could not even be scored (qids 586/587 on the codebase_community eval). The Postgres backend has been advertised since day one; nothing had ever actually run a LIKE against it. A DBAPI cursor executed with no parameters interprets nothing, which is the only path that survives both traps at once: psycopg's `%` and SQLAlchemy `text()`'s `:__` (the latter is why the code left `text()` in the first place — BIRD qids 959/989/990). Driver-native exceptions are re-wrapped into `sqlalchemy.exc` so that callers' error classification (execution/runner.py) keeps working. Live-Postgres tests pin LIKE (prefix/suffix/contains), the modulo operator, and colon literals. SQLite path is unchanged: re-measured n=200 → still 57.5%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ac852b commit c8742c6

2 files changed

Lines changed: 72 additions & 7 deletions

File tree

‎src/nl_sql/db/connection.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from sqlalchemy import Connection, Engine, create_engine, text
2222
from sqlalchemy.engine import make_url
23+
from sqlalchemy.exc import DBAPIError
2324

2425
Dialect = Literal["sqlite", "postgresql"]
2526

@@ -126,13 +127,36 @@ def execute_readonly(
126127
started = time.perf_counter()
127128
with engine.connect() as conn:
128129
_apply_runtime_limits(conn, statement_timeout_ms)
129-
# exec_driver_sql bypasses SQLAlchemy's :name bind-parameter parsing,
130-
# which would otherwise misinterpret colons inside string literals such
131-
# as `LIKE '_:%:__.___'` (BIRD qids 959 / 989 / 990 — formula_1 time patterns).
132-
cursor = conn.exec_driver_sql(sql)
133-
columns = list(cursor.keys())
134-
rows = cursor.fetchmany(row_cap + 1)
135-
cursor.close() # drain any remaining rows so SQLite/Postgres release resources
130+
# Run on a DBAPI cursor with NO parameters, because every parameterised
131+
# path mangles a literal that real SQL contains:
132+
# - SQLAlchemy `text()` reads `:__` as a bind parameter, breaking gold
133+
# like `LIKE '_:%:__.___'` (BIRD qids 959/989/990, formula_1 times).
134+
# - `exec_driver_sql` passes an (empty) parameter set to the driver, so
135+
# psycopg's pyformat parser scans for placeholders and rejects `%`:
136+
# `LIKE '%variance%'` → "only '%s', '%b', '%t' are allowed as
137+
# placeholders". That killed LIKE filters *and* the `%` modulo
138+
# operator on Postgres — see tests/test_postgres_readonly.py.
139+
# A cursor executed with no parameters interprets neither, on either driver.
140+
cursor = conn.connection.cursor()
141+
try:
142+
cursor.execute(sql)
143+
columns = [str(d[0]) for d in cursor.description or ()]
144+
rows = cursor.fetchmany(row_cap + 1)
145+
except Exception as raw_exc:
146+
# Going through the raw cursor also means driver-native exceptions
147+
# (sqlite3.OperationalError, psycopg.Error) instead of SQLAlchemy's.
148+
# Callers classify failures on sqlalchemy.exc types — timeouts on
149+
# OperationalError, everything else on SQLAlchemyError (see
150+
# execution/runner.py) — so re-wrap and keep that contract intact.
151+
raise DBAPIError.instance(
152+
sql,
153+
None,
154+
raw_exc,
155+
engine.dialect.loaded_dbapi.Error,
156+
dialect=engine.dialect,
157+
) from raw_exc
158+
finally:
159+
cursor.close() # release server-side resources / drain remaining rows
136160
truncated = len(rows) > row_cap
137161
if truncated:
138162
rows = rows[:row_cap]

‎tests/test_postgres_readonly.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,44 @@ def test_execute_readonly_runs_a_real_select(readonly_engine: Engine) -> None:
108108
assert result.columns == ["id", "v"]
109109
assert result.rows == [(1, "seed")]
110110
assert result.truncated is False
111+
112+
113+
@pytest.mark.parametrize(
114+
("sql", "expected"),
115+
[
116+
pytest.param(
117+
f"SELECT v FROM {_TABLE} WHERE v LIKE '%see%'", [("seed",)], id="like-contains"
118+
),
119+
pytest.param(f"SELECT v FROM {_TABLE} WHERE v LIKE 'see%'", [("seed",)], id="like-prefix"),
120+
pytest.param(f"SELECT v FROM {_TABLE} WHERE v LIKE '%eed'", [("seed",)], id="like-suffix"),
121+
pytest.param(f"SELECT 7 % 2 FROM {_TABLE}", [(1,)], id="modulo-operator"),
122+
pytest.param(f"SELECT ':__' FROM {_TABLE}", [(":__",)], id="colon-literal"),
123+
pytest.param(
124+
f"SELECT v FROM {_TABLE} WHERE 'a:1' LIKE '%:%'", [("seed",)], id="colon-and-percent"
125+
),
126+
],
127+
)
128+
def test_execute_readonly_passes_literals_through_verbatim(
129+
readonly_engine: Engine, sql: str, expected: list[tuple[object, ...]]
130+
) -> None:
131+
"""Neither `%` nor `:` inside the SQL may be read as a bind placeholder.
132+
133+
Two drivers, two opposite traps, and the statement has to survive both:
134+
135+
- psycopg speaks `pyformat`, so anything routed through a *parameterised*
136+
execute makes the driver scan for `%s`-style placeholders. `LIKE '%see%'`
137+
then dies with "only '%s', '%b', '%t' are allowed as placeholders, got
138+
'%s'e" — and a LIKE filter is the single most common shape a text-to-SQL
139+
model emits. This is not hypothetical: BIRD qids 586/587 (`LIKE
140+
'%variance%'`, `LIKE '%<humor>%'`) failed on the Postgres eval, and the
141+
failure hit BIRD's own *gold* SQL too, so the question could not even be
142+
scored.
143+
- SQLAlchemy's `text()` reads `:__` as a bind parameter, which breaks BIRD
144+
gold like `LIKE '_:%:__.___'` (qids 959/989/990) — see the SQLite twin of
145+
this test in tests/test_db_connection.py.
146+
147+
The only path that interprets neither is a DBAPI cursor executed with no
148+
parameters at all, which is what `execute_readonly` must do.
149+
"""
150+
with execute_readonly(readonly_engine, sql) as result:
151+
assert result.rows == expected

0 commit comments

Comments
 (0)