Skip to content

Exec routes: run the request's SQL directly (CREATE ROUTE … AS $sql) - #48

Merged
asubbarao merged 2 commits into
mainfrom
feat/exec-sql-route
Oct 16, 2026
Merged

asubbarao merged 2 commits into
mainfrom
feat/exec-sql-route

Conversation

@asubbarao

Copy link
Copy Markdown
Owner

What

A route whose handler is exactly one parameter is an exec route:

CREATE OR REPLACE ROUTE sql POST '/sql' AS $sql;

quackapi recognises the single-$param handler, skips CREATE-time validation for it, and at request time executes the bound text as SQL on the request's connection. This lets a deployment serve /sql directly instead of looping back through quack_query('quack:localhost:…', $sql) (a second Quack connection per request).

Design

  • Grammar: AS $<name> — the handler, trimmed (trailing ; stripped), is one $identifier and nothing else. $claims_* never qualifies (server-bound). Anything else is an ordinary handler and is still prepared/validated at CREATE.
  • Binding: the request prepares a stand-in (SELECT $sql) so the parameter goes through the normal binding path: JSON body field, form field, query string, HEADER/COOKIE, and PARAM … MIN_LENGTH / DEFAULT all apply. The number-like-integer heuristic is skipped for the exec parameter (it is always VARCHAR SQL text).
  • Execution: Connection::ExtractStatements (DuckDB's parser, incl. PRAGMA expansion) then Connection::Query(statement) for each statement in order — DDL, DML, PRAGMA, SET, INSTALL/LOAD, COPY. Each statement runs once.
  • Result selection: the last statement's result (Query-farm/httpserver uses Connection::Query(string), which answers with the first row-producing statement). It flows through the existing response path: JSON default, Accept: text/csv / NDJSON / Arrow, route FORMAT, html/text columns, max_response_bytes. A final statement with no columns (SET, call-PRAGMA) answers [] rather than an empty body.
  • Errors: the first failing statement stops the batch (later statements do not run) and answers 422 with the FastAPI-shaped detail: loc = the parameter's location (["body","sql"], ["query","q"]), type = value_error, msg = DuckDB's error, prefixed Statement i of n failed: for batches. Earlier statements are autocommitted, as in the DuckDB CLI.
  • Limits/security: REQUIRE auth runs before any request SQL; query_timeout_ms bounds the whole batch (504). Row-access/masking policies cannot rewrite arbitrary SQL, so while any are bound the exec route fails closed (403, existing policy path). The pg_dsn native path and BODY TYPE rewrite are skipped for exec routes. OpenAPI documents the parameter and advertises no response columns.

Tests

test/sql/quackapi_exec_route.test (81 assertions, all pass locally against the freshly built test binary):
single SELECT; zero-row SELECT; multi-statement returns the last; DDL → DML → SELECT; TEMP table shared within a request and gone after; trailing SET → []; each statement once (INSERT side-effect counter + PRAGMA appears exactly once in DuckDB's QueryLog); error mid-batch → 422 with loc/type, Statement 2 of 3 failed, statement 1 committed and statement 3 not run; parser error runs nothing; conversion error 422; empty SQL / missing field 422; CSV body + NDJSON via Accept; html column mode; PARAM sql VARCHAR MIN_LENGTH 422/200; GET query-string exec route; REQUIRE 401 runs no SQL / 200 with key; row-access policy 403 then 200 after unbind; query_timeout_ms 504 on a batch; OpenAPI lists the route.

Real HTTP (test/conformance, 7 new cases): JSON, last-statement batch, CSV, Arrow (Accept: application/vnd.apache.arrow.stream, via community nanoarrow), 422 error shape, auth 401/200. Locally: 92 PASS / 0 FAIL.

Full test/sql/quackapi*: all pass (3308 assertions, 2 skipped). make format-ci-check clean.

Not in this PR

Switching dev's /sql route from quack_query to AS $sql happens after merge + release.

🤖 Generated with Claude Code

asubbarao and others added 2 commits October 14, 2026 15:55
A route whose handler is exactly one parameter, e.g.

  CREATE ROUTE sql POST '/sql' AS $sql;

is an exec route. CREATE skips handler validation for it. At request time
the parameter binds through the normal path (PARAM constraints such as
MIN_LENGTH still apply), then its text is split with DuckDB's parser and
every statement runs once, in order, on the request connection: DDL, DML,
PRAGMA, SET, INSTALL/LOAD, COPY. The LAST statement's result is the
response through the existing format negotiation (JSON, CSV, NDJSON,
Arrow, route FORMAT, html/text columns); a final no-column statement
answers []. The first failing statement stops the batch and answers 422
with the FastAPI-shaped detail on the parameter's loc. REQUIRE runs
before any request SQL, query_timeout_ms bounds the batch (504), and a
bound row-access/masking policy makes the route fail closed (403).

This lets a deployment serve /sql without looping back through
quack_query (a second connection per request).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DuckDB's statement extraction truncates at the first null byte, so a
batch like 'SELECT 111 AS first<NUL>garbage' executed the prefix and
silently dropped the tail while returning 200. Null bytes are never
legitimate in request SQL text; fail fast with a 422 naming the
parameter instead.
@asubbarao
asubbarao merged commit a3c88f3 into main Oct 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant