Writing SQL endpoints
Every .sql file under sql_dir becomes one HTTP endpoint. This chapter
covers the directory layout, parameter binding, result shaping, and the
batch API.
Directory layout → URL
The scanner expects three levels:
<sql_dir>/<project>/<GET|POST>/<name...>.sql
<project>— the first URL segment. Also the default datasource name.<GET|POST>— an all-caps method directory. Other names (includingPUT,DELETE) are silently ignored.<name...>.sql— filename with an optional subdirectory prefix.
Resolved endpoint:
<METHOD> /<project>/<name...>
Examples:
| File | Endpoint |
|---|---|
sql/crm/GET/users/find-by-login.sql | GET /crm/users/find-by-login |
sql/crm/POST/users/create.sql | POST /crm/users/create |
sql/reports/GET/daily/sales-by-region.sql | GET /reports/daily/sales-by-region |
sql/analytics/POST/rollup.sql | POST /analytics/rollup |
Rules:
- Project and path lookups are case-insensitive.
/CRM/findand/crm/findroute to the same file. - Non-
.sqlfiles are ignored (README notes, .keep files, etc.). - Duplicate endpoints (same case-insensitive key) fail startup.
- Empty files fail startup.
- Startup fails loudly if
sql_dirdoesn't exist.
Named parameters
Use :name in your SQL. Any JSON body key with a matching name binds
to it. Repeated :name binds the same value at every position.
-- sql/crm/POST/users/find.sql
SELECT id, email
FROM users
WHERE (:login IS NULL OR login = :login)
AND (:status IS NULL OR status = :status);
curl -X POST http://localhost:8080/crm/users/find \
-H "content-type: application/json" \
-d '{"login":"alice","status":null}'
The parser understands:
- String literals (
'…',"…") with doubled-quote escapes —:xyzinside a string is not a parameter. - Line comments (
-- …) and block comments (/* … */). - Postgres cast syntax (
::type) is left alone.
For GET endpoints, use the query string. Every key becomes a bind
target with the value as a string:
curl "http://localhost:8080/crm/users/find?login=alice"
Missing / extra parameters
- Missing: any
:namein the SQL that has no matching JSON key → HTTP 400 witherror: InvalidDataAccessApiUsageExceptionand the parameter name in the message. - Extra: extra JSON keys are silently ignored.
Type coercion
JSON values bind at their natural type:
null→ SQL NULLtrue/false→ BOOLEAN- integer → 64-bit integer
- floating-point → 64-bit double
- string → text
- arrays / objects (Postgres only) → JSONB (bind with
::jsonbcast if you want the DB to enforce it)
For dates and timestamps, cast in SQL:
INSERT INTO events (occurred_at)
VALUES (cast(:occurredAt AS TIMESTAMPTZ));
Result columns
Every result column is renamed from snake_case to camelCase:
user_id→userIdPASSWORD_HASH→passwordHashid→id(single tokens are unchanged)
Results come back as a JSON array of objects. DDL and other statements
with no result set return [].
Datasource selection
For a URL /<project>/<name>:
- If
X-Datasource: <name>is present andallow_datasource_headeristrue, that name wins. - Otherwise, if
project_datasource_maphas an entry for<project>, the mapped name is used. - Otherwise,
<project>itself is the datasource name.
If the resolved name is not in datasources, the response is HTTP 400
with error: UnknownDataSourceNameException.
Batch endpoint
Any POST endpoint accepts a batched variant at the same path with
/batch appended. The body wraps a list of parameter objects; the
response is a list-of-lists, one entry per input.
curl -X POST http://localhost:8080/crm/users/find/batch \
-H "content-type: application/json" \
-d '{"queries":[{"login":"alice"},{"login":"bob"}]}'
# [[{"id":1,"email":"alice@x"}], [{"id":2,"email":"bob@x"}]]
Atomicity guarantee (since v0.1.0-alpha.2): the whole batch runs inside a single database transaction. Every iteration binds and executes through the same transaction; a failure on any iteration rolls the entire transaction back — no partial writes ever land. The response body on failure is the standard 400 shape as if the failing query had been sent on its own.
# If the 2nd iteration violates a UNIQUE constraint, iterations 1 AND 3
# are rolled back too. A subsequent SELECT sees zero of these rows.
curl -X POST http://localhost:8080/crm/users/create/batch \
-H "content-type: application/json" \
-d '{"queries":[{"login":"a"},{"login":"a"},{"login":"c"}]}'
# → 400 {"error":"BadSqlGrammarException", "message":"..."}
Missing-parameter checks run against every parameter set BEFORE the transaction opens, so a batch that couldn't possibly succeed fails fast without any DB round-trips.
Soft ceiling: batching 10⁴ rows in one request works fine, but the transaction holds row locks until commit. Consumers doing 10⁵+ row loads should split into multiple batches at the caller.
Native array parameters (Postgres)
When a JSON parameter is an array of homogeneous scalars, it binds
natively as a Postgres array (text[], int8[], float8[], bool[])
so a single SQL statement using unnest() can process the whole batch
in one round-trip:
-- sql/audit/POST/append-many.sql
INSERT INTO audit_log (actor, action)
SELECT unnest(:actors), unnest(:actions)
RETURNING id;
curl -X POST http://localhost:8080/audit/append-many \
-H "content-type: application/json" \
-d '{"actors":["alice","bob"],"actions":["created","updated"]}'
# → 200 [{"id":42}, {"id":43}] (one INSERT, two rows)
Rules:
-
Non-null elements must all be the same scalar kind. Nulls are permitted anywhere and become SQL NULL elements.
-
Mixed kinds (
[1, "two", true]), nested ([[1,2],[3,4]]), all-null, and empty arrays fall back to JSONB binding — usejsonb_array_elements*in your SQL to unpack. -
Int + float mixed promotes to
float8[]. -
SQLite has no native array type. Arrays bind as a JSON string; use
json_each()to unpack:SELECT value AS actor FROM json_each(:actors);
Per-file @transactional marker
Add -- @transactional as the very first comment in a SQL file to
have the endpoint's execution wrapped in a single database transaction
(commit on success, rollback on any error). Batch endpoints are always
transactional regardless — this marker is for single-shot POST
endpoints whose SQL contains multiple statements or where the caller
wants explicit rollback semantics on failure.
-- @transactional
INSERT INTO audit_log (actor, action) VALUES (:actor, :action);
UPDATE users SET last_seen = now() WHERE id = :user_id;
Rules:
- Only recognised in the leading comment block. Once real SQL starts, no further marker is honoured.
- Applies to both GET and POST (transaction is essentially free for a read-only statement; primary use case is POST).
- Absence of the marker preserves the pre-v0.1.0-alpha.2 behaviour of auto-commit per statement.