OriginChainDB docs
reference · sql

SQL reference

OriginChainDB runs SQL against the same instance that holds your vectors, full-text indexes, and graph relationships. The panel below separates what runs today from what does not, and every shape has a copy-pasteable example in cURL, Python, TypeScript and Go.

All examples below assume you have a client set up. If you haven't yet, see Quickstart.

At a glance.

works today
  • SELECT — projection, *, DISTINCT
  • WHERE= != < <= > >= BETWEEN IN IS NULL LIKE, combined with AND / OR / NOT
  • ORDER BY (multi-column, ASC/DESC), LIMIT, OFFSET
  • GROUP BY + COUNT, SUM, AVG, MIN, MAX, HAVING, COUNT(DISTINCT)
  • JOIN — INNER, LEFT, RIGHT, FULL OUTER (up to 32 tables), and JOIN + GROUP BY
  • Window functions — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM/AVG OVER (PARTITION BY), with ROWS / RANGE frame clauses
  • SubqueriesIN / EXISTS / scalar, correlated and uncorrelated (across tables)
  • CTEsWITH … AS over real tables, with filters, joins and aggregates, plus a single WITH RECURSIVE (UNION ALL only)
  • Set operationsUNION, UNION ALL, INTERSECT, EXCEPT, chainable across three or more SELECTs
  • CASE expressions — CASE WHEN … THEN … ELSE … END in the SELECT list
  • INSERT, UPDATE, DELETE — set-based, matched by a WHERE across many rows
  • Transactions — BEGIN / COMMIT / ROLLBACK, atomic even across nodes — see Transactions
  • DDLCREATE / DROP TABLE & SCHEMA (IF NOT EXISTS), PRIMARY KEY, DEFAULT, FOREIGN KEY (inline + table), SERIAL / IDENTITY, ALTER … ADD CONSTRAINT, CREATE INDEX / VIEW, EXPLAIN
  • Parameters — positional $1 bound from a params array
  • Catalog & sessionpg_catalog / information_schema introspection, SET / SHOW (search_path, GUCs), current_schema()
  • Time-bucketingdate_trunc over a timestamp column in GROUP BY (the dashboard shape)
not yet
  • Scalar subquery in the SELECT listSELECT (SELECT …) AS x; in a WHERE comparison it ships
  • Qualified projection off a derived tableSELECT d.c FROM (…) d; SELECT c and SELECT * over the same derived table work
  • nextval() as a column DEFAULT or INSERT value — supply an epoch literal or a bound param

Try anything in the "not yet" list and you get a 400 with a clear reason. Nothing is silently re-interpreted.

The endpoint.

POST /v1/tenants/:tenant/sql
Authorization: Bearer $OC_TOKEN
Content-Type:  application/json

{ "sql": "SELECT ..." }

The response shape depends on the statement kind. Every response carries a "kind" field so your code can switch on it:

kind When What's in the body
"select" SELECT rows: [{...}, ...] - one object per row.
"explain" EXPLAIN plan - the pretty-printed plan tree, plus stats on EXPLAIN ANALYZE. EXPLAIN does not carry kind "select".
"insert" INSERT schema, rows - the inserted rows. INSERT executes and enforces foreign keys. See Writes below.
"update" UPDATE rows_affected - UPDATE executes for every row the WHERE clause matches. A predicate on the primary key takes a fast path; any other supported predicate lowers to a scan.
"delete" DELETE schema, pk, rows_affected - DELETE executes for every row the WHERE clause matches and is durable when the response returns; a transaction is not required. See Writes.

Column types.

You declare a column's type when you register a schema (see schemas) or with CREATE TABLE. The engine validates every value against it. These are the types it stores:

Type SQL aliases Holds
i64 / u64INT, BIGINT, SMALLINTSigned / unsigned integers.
f64FLOAT, REAL, DOUBLEFloating-point numbers.
decimalDECIMAL(p,s), NUMERICExact fixed-point (money).
str / textTEXT, VARCHAR, CHARUTF-8 strings.
boolBOOL, BOOLEANTrue / false.
timestamp / dateTIMESTAMP, DATETIME, DATEInstants / calendar dates.
uuidUUIDFormat-validated UUIDs.
enumENUM(...)One of a fixed set of strings.
jsonJSON, JSONBArbitrary JSON values.
listARRAYOrdered lists.
bytesBLOB, BYTEA, BINARYRaw byte strings.
inet / interval / pointINET, INTERVAL, POINTIP addresses, durations, geo points.

1. SELECT basics.

what this does

Read rows back from a table. Pick which columns you want, filter with WHERE, cap the result count with LIMIT.

POST /v1/tenants/:t/sql
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT id, name, price_cents FROM shop.products WHERE category = '\''electronics'\'' LIMIT 50"
  }'
common mistakes
  • ORDER BY runs server-side. Sort by one or more columns with ASC/DESC, and page through results with LIMIT + OFFSET. (Window frame clauses like ROWS BETWEEN are supported; GROUPS mode and EXCLUDE are not.)
  • Missing LIMIT. A SELECT without LIMIT returns every matching row. For large tables this can be slow. Always include a LIMIT during development.
  • Schema vs. table name. Use the full schema.table form (here, shop.products). The bare table name without the schema doesn't resolve.

2. WHERE filters.

what this does

Narrow down which rows come back. Combine any number of conditions with AND, OR and NOT.

POST /v1/tenants/:t/sql
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT id, name, price_cents FROM shop.products WHERE category = '\''electronics'\'' AND price_cents > 10000 AND price_cents < 50000"
  }'
operators you can use
Operator Example
= != < <= > >= price_cents > 1000
BETWEEN price_cents BETWEEN 1000 AND 20000
IN (list) category IN ('electronics', 'books')
IS NULL / IS NOT NULL description IS NOT NULL
LIKE name LIKE 'Wireless%'
common mistakes
  • AND, OR, and NOT all work. Combine them freely — e.g. WHERE (category = 'books' OR category = 'toys') AND price_cents < 5000. IN (...) is still the tidiest way to match a set of values.
  • No bind parameters. Inline literal values - $1 and ? placeholders are not supported yet. If you build SQL from user input, escape strings carefully.
  • String quoting in cURL. Single quotes inside a JSON string need to be escaped as '\\''. Easier to use a Python / TS / Go SDK for any non-trivial query.

3. GROUP BY + aggregates.

what this does

Roll rows up by one or more columns and compute aggregates (counts, sums, averages, min/max). Useful for any "how many X per Y" question.

POST /v1/tenants/:t/sql
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT category, COUNT(*) AS n, SUM(price_cents) AS total, AVG(price_cents) AS avg_price FROM shop.products GROUP BY category"
  }'
supported aggregate functions
Function What it returns
COUNT(*) Number of rows in the group.
COUNT(col) Number of non-null values of col.
SUM(col) Sum of values.
AVG(col) Arithmetic mean of values.
MIN(col), MAX(col) Smallest / largest value.
common mistakes
  • Use HAVING to filter groups. WHERE filters rows before grouping; HAVING filters groups after — e.g. SELECT category, COUNT(*) AS n … GROUP BY category HAVING COUNT(*) > 3. The aggregate you filter on has to appear in the SELECT list too. Both run server-side.
  • DISTINCT aggregates work. COUNT(DISTINCT col), SUM(DISTINCT col), and AVG(DISTINCT col) all execute. (COUNT(DISTINCT *) is the one form that isn't allowed.)
  • Every selected column needs to be in GROUP BY or an aggregate. Standard SQL rule - if you select a column you didn't group by, you'll see 400.

4. JOIN tables.

what this does

Combine rows from two or more tables on a matching column. INNER, LEFT, RIGHT, and FULL OUTER joins are supported. Up to 32 tables in one query.

POST /v1/tenants/:t/sql
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT o.id, o.qty, p.name FROM shop.orders o INNER JOIN shop.products p ON o.product_id = p.id WHERE o.status = '\''paid'\'' LIMIT 100"
  }'
join types
Type What you get
INNER JOIN Only rows that have a match on both sides.
LEFT JOIN Every row from the left side, plus matches from the right (null if no match).
RIGHT JOIN Mirror of LEFT - every row from the right side, plus matches from the left.
FULL OUTER JOIN Every row from both sides; nulls fill the gaps.
common mistakes
  • CROSS JOIN is narrow. Only the two-table SELECT * FROM a CROSS JOIN b form works, optionally with a LIMIT. A third table, a mix with INNER / OUTER joins, or a WHERE, ORDER BY, OFFSET, DISTINCT or explicit projection on one returns 400 — use an explicit ON condition instead.
  • Ambiguous column names. When two tables have the same column name, qualify with the alias (o.id, p.id) - otherwise the parser refuses.
  • 33+ tables. The cap is 32 tables per query. Larger joins return 400. (You almost never want more than 5 in practice.)

5. Writes via SQL (preview).

INSERT and UPDATE execute against the engine. INSERT writes the rows and enforces foreign keys; UPDATE changes every row its WHERE clause matches, taking a fast path when that predicate is the primary key and returns rows_affected. For high-volume ingest, the dedicated row endpoints are still the fastest path.

POST /v1/tenants/:t/sql - INSERT
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "INSERT INTO shop.products (id, name, category, price_cents) VALUES ('\''p-x-1'\'', '\''New Product'\'', '\''electronics'\'', 5555)"
  }'
response
{
  "kind":   "insert",
  "schema": "shop.products",
  "rows":   [
    { "id": "p-x-1", "name": "New Product", "category": "electronics", "price_cents": 5555 }
  ]
}
deletes

You can run row deletes inside a transactionBEGIN; DELETE FROM shop.products WHERE id = 'p-x-1'; COMMIT; — which buffers the delete and applies it on commit. A bare DELETE outside a transaction executes immediately and is durable when the response returns. Both UPDATE and DELETE are set-based — they act on every row the WHERE clause matches. See Transactions for the full lifecycle, error handling, and retry pattern.

6. EXPLAIN.

what this does

Prefix any SELECT with EXPLAIN to see the query plan the engine would run, without executing it. Useful for checking whether your indexes are being used.

POST /v1/tenants/:t/sql
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "EXPLAIN SELECT id, name FROM shop.products WHERE category = '\''electronics'\''"
  }'

The plan tree includes operator names like Scan, Filter, IndexScan, HashJoin, Aggregate. If you see Scan where you expected IndexScan, you likely need an [[indexes]] declaration on the schema.

Examples.

SQL is the general-purpose surface: reach for it whenever the question is which rows rather than this row — filtering, sorting, counting, summing, joining — and for the writes the row endpoints do not cover, like a partial update or a delete. One endpoint takes a statement, executes it, and returns JSON.

It is a large, real SQL subset rather than a full dialect, and this page is written to be exact about the boundary. Everything listed as supported was read off the translator and the executor; everything refused is refused with an error, not silently ignored.

7

The endpoint contract.

POST /v1/tenants/:tenant/sql. The body has exactly two fields — sql (required) and params (optional). There is no namespace field, no limit field and no result-format field; the statement carries all of that.

One statement per request. Two statements separated by a semicolon are refused.

Every success carries a kind discriminator naming what ran — select, insert, update, delete, explain, tx, or one of the schema-change kinds. Branch on it; do not assume a rows array is there.

row keys are alphabetical, not projection order

Each row is a JSON object, and its keys serialize in alphabetical orderSELECT id, customer comes back with customer first. That is why a columns array is included on the response: it carries the real projection order. Anything that renders a table or writes a CSV should read columns, not the key order of the first row.

columns is omitted for SELECT *

The array is only present when the plan declares a static projection order. SELECT *, a join wildcard, and a set operation each declare none, so the field is left off the response entirely — not sent as an empty array. If column order matters to you, list the columns explicitly.

8

SELECT.

projection with WHERE, ORDER BY and LIMIT
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT id, customer, amount_cents FROM shop.orders WHERE status = '\''paid'\'' ORDER BY placed_ms DESC LIMIT 3"
  }'
response200
{
  "kind": "select",
  "columns": ["id", "customer", "amount_cents"],
  "rows": [
    { "amount_cents": 8900, "customer": "cus-12", "id": "ord-2002" },
    { "amount_cents": 4200, "customer": "cus-77", "id": "ord-1001" },
    { "amount_cents": 1500, "customer": "cus-12", "id": "ord-2001" }
  ]
}

Note the keys inside each row are ALPHABETICAL, not projection order. "columns" is the projection order - use it if order matters.

Clause by clause

Clause Runs Boundaries
SELECT * / column list / expressions / AS aliases yes Mixing * with expression projections is refused — list the columns.
SELECT DISTINCT yes DISTINCT ON (…) is refused. DISTINCT with GROUP BY or a window function is refused.
WHERE yes See the operator table above.
ORDER BY col [ASC|DESC], … yes Bare column names or a projection position only. Functions and expressions are refused, and there is no NULLS FIRST / NULLS LAST.
LIMIT n [OFFSET m] yes OFFSET without LIMIT works. FETCH FIRST … ROWS ONLY is refused — use LIMIT.
GROUP BY … / HAVING … yes Included from the Thunder configuration up. ROLLUP / CUBE / GROUPING SETS are refused.
JOIN (INNER / LEFT / RIGHT / FULL / CROSS) yes Included from the Thunder configuration up. See the join rules below.
Subqueries in WHERE yes IN, EXISTS and scalar =, correlated or not. One level of nesting. A scalar subquery in the SELECT list is refused. Over a derived table (FROM (SELECT …)) an unqualified SELECT c or SELECT * works; a qualified SELECT d.c is refused.
WITH … AS (…) · WITH RECURSIVE yes Recursive form is base UNION ALL recursive, depth-capped at 100. Nested WITH and column-list renaming are refused.
UNION · UNION ALL · INTERSECT · EXCEPT yes Outer ORDER BY / LIMIT / OFFSET wrap the combined result.
Window functions OVER (…) yes ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTILE, and SUM / AVG / MIN / MAX / COUNT. Single-table SELECT list only — refused alongside JOIN, GROUP BY or DISTINCT.
CASE WHEN … THEN … END yes Both the searched and the simple form.
CAST(x AS t) · x::t yes TRY_CAST and SAFE_CAST are refused.
SELECT with no FROM partly A constant expression like SELECT 1 + 2 AS three works. A bare SELECT 1 that a driver sends as a liveness probe does not — every SELECT that names a column must name a table.

WHERE operators

Operator Notes
= != <> < <= > >= Against a literal, another column, or an expression.
AND · OR · NOT · ( ) Arbitrary boolean trees.
IN (…) · NOT IN (…) Literal lists and subqueries, correlated or not. Three-valued null logic.
BETWEEN a AND b Closed interval, and the form that gets index range pushdown. NOT BETWEEN is refused.
IS NULL · IS NOT NULL
LIKE · NOT LIKE · ILIKE · NOT ILIKE The ESCAPE clause is refused.
EXISTS · NOT EXISTS Correlated and uncorrelated.
= (SELECT …) Scalar subquery. Only with =; the ordering comparators are refused. Subqueries of every form are refused outright on a tenant with read-side RBAC configured, or an identity-less READ posture of deny - the subquery executor cannot enforce per-user reads, so it fails closed.

Scalar functions usable in a projection or a predicate:

NOW() / CURRENT_TIMESTAMP · LOWER · UPPER · LENGTH / CHAR_LENGTH · COALESCE · NULLIF · ABS · ROUND · FLOOR · CEIL / CEILING · MOD · POWER / POW · SQRT · CONCAT · SUBSTRING / SUBSTR · TRIM / BTRIM / LTRIM / RTRIM · REPLACE · POSITION / STRPOS

Anything outside that list is refused with a message enumerating what is available. Note two absences people reach for: the || string-concatenation operator is not available in a row expression — use CONCAT(a, b) — and there are no date-part extraction functions.

9

Bind parameters.

Placeholders are PostgreSQL-style and positional: $1, $2, and so on, filled from the params array in order. Values are substituted into the parsed statement as literals — never spliced into the text — so a parameter can change what a query matches but can never change what the statement does. Use them for anything that came from a user.

parameterised SELECT
# Placeholders are PostgreSQL-style and positional: $1, $2, ...
# params[0] fills $1. JDBC-style "?" is refused.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql":    "SELECT id, amount_cents FROM shop.orders WHERE status = $1 AND amount_cents > $2 LIMIT $3",
    "params": ["paid", 1000, 20]
  }'
  • Scalars only. Strings, numbers, booleans and null. A JSON array or object as a parameter is refused — you cannot bind a list to IN ($1); generate IN ($1, $2, $3) instead.
  • The count must match exactly, both ways. A supplied value the statement never references is an error, and so is a $3 with only two values. Referencing the same placeholder twice is fine.
  • ? is not accepted. The error says so explicitly. Numbering starts at $1$0 is out of range.
  • They work in LIMIT too, and in INSERT … VALUES and UPDATE … SET.
  • Not on transaction verbs. Sending params alongside BEGIN, COMMIT or ROLLBACK is a 400.
client support is uneven right now

The TypeScript and Go clients both send a positional array, which is what the engine binds. The Python client's params= argument sends a named mapping against :name placeholders — a shape this engine does not accept, so the request is rejected before the statement is parsed. From Python, bind through the HTTP API as shown above until the client is updated.

10

Aggregates & GROUP BY.

There are exactly five aggregate functions: COUNT, SUM, AVG, MIN and MAX. Anything else — standard deviation, string aggregation, percentiles — is refused with that list in the message.

GROUP BY with HAVING
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT customer, COUNT(*) AS orders, SUM(amount_cents) AS total FROM shop.orders WHERE status = '\''paid'\'' GROUP BY customer HAVING SUM(amount_cents) > 5000 ORDER BY total DESC LIMIT 10"
  }'
response200
{
  "kind": "select",
  "columns": ["customer", "orders", "total"],
  "rows": [
    { "customer": "cus-12", "orders": 2, "total": 10400 }
  ]
}

GROUP BY and HAVING are included from the Thunder configuration up - see section 14.

  • COUNT(*) is the only wildcard form. COUNT(DISTINCT x), SUM(DISTINCT x) and AVG(DISTINCT x) all work; DISTINCT on MIN or MAX is accepted but has no effect (it cannot).
  • Expression arguments work — SUM(amount_cents + shipping_cents), COUNT(DISTINCT LOWER(customer)).
  • Always give an aggregate an AS alias. Without one the output column is auto-named from the expression — sum(amount_cents), count(*) — which is awkward to index in every client language.
  • HAVING compares an aggregate or a grouped column against a literal, combined with AND / OR. Comparing one aggregate against another is refused, and HAVING without GROUP BY is refused.
  • SUM and AVG over a date, time or timestamp column are refused — the same way PostgreSQL refuses them.
aggregates are the one shape that streams

An aggregate directly over a scan or a filtered scan is computed as rows arrive, so SELECT COUNT(*) or SUM(...) over a very large table works without buffering it. That is not true of a projection — see limits.

11

Joins.

INNER, LEFT, RIGHT and FULL OUTER all work, plus a two-table CROSS JOIN. A comma join with a single equality in the WHERE is promoted to an inner join for you.

INNER JOIN
# One equality per ON clause, both sides qualified: alias.column.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT o.id, c.name, o.amount_cents FROM shop.orders o INNER JOIN shop.customers c ON o.customer = c.id WHERE o.status = '\''paid'\'' LIMIT 10"
  }'

# Projected columns keep their alias prefix: the result columns are
# literally "o.id", "c.name", "o.amount_cents".
#
# JOIN is included from the Thunder configuration up - see section 14.
one equality per ON clause — no AND, no inequality

An ON clause must be exactly alias.column = alias.column. A composite join condition — ON a.x = b.x AND a.y = b.y — is refused, and so is any non-equality such as ON a.t > b.t. If you need a composite key, join on one column and filter the rest in the WHERE. The same limit means JOIN … USING (a, b) with more than one column is refused, though a single-column USING works.

  • Projected columns keep their alias. SELECT o.id gives you a column literally named o.id. Rename in your client if you need something else.
  • Up to 32 tables in one FROM; join chains are evaluated left to right.
  • A JOIN with no ON is refused, and an alias cannot be reused across joins.
  • GROUP BY, DISTINCT, ORDER BY and OFFSET all compose over a join. Window functions do not — that combination is refused.
a typo on the right-hand table returns zero rows, not an error

Column names are validated against the left-most table's manifest only. A misspelled column on a joined-in table passes validation and never matches, so the query succeeds with an empty result. If a join returns nothing and you expected rows, check the spelling on the right-hand side first.

avoid NATURAL JOIN

It is accepted, but it infers the join keys from the left table alone and trusts that the right table has them. When that assumption is wrong you get zero rows rather than an error. Write the ON clause out.

12

Writes and schema changes.

Write statements execute — they are not translated into something you then have to re-issue. A successful INSERT, UPDATE or DELETE is durable when the response returns.

INSERT with RETURNING
# INSERT executes and writes durably. The column list is MANDATORY.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "INSERT INTO shop.orders (id, customer, amount_cents, status, notes, placed_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, status",
    "params": ["ord-3001", "cus-90", 2500, "pending", "", 1714478400000]
  }'

# Explicit upsert instead:
#   INSERT INTO shop.orders (id, status) VALUES ('ord-3001', 'shipped')
#   ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status
response200
{
  "kind":      "insert",
  "schema":    "shop.orders",
  "inserted":  1,
  "returning": ["id", "status"],
  "rows":      [ { "id": "ord-3001", "status": "pending" } ]
}
response409
{ "error": "constraint_violation", "detail": "..." }

A duplicate primary key is a hard error - the existing row survives:

UPDATE and DELETE
# UPDATE executes. A WHERE clause is MANDATORY, and you cannot SET a
# primary-key column. RETURNING works on UPDATE.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{"sql":"UPDATE shop.orders SET status = $1 WHERE customer = $2",
       "params":["shipped","cus-12"]}'

# DELETE executes too, and DOES support RETURNING.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{"sql":"DELETE FROM shop.orders WHERE status = $1 RETURNING id",
       "params":["cancelled"]}'
response
{ "kind": "update", "schema": "shop.orders", "rows_affected": 2 }
response
{ "kind": "delete", "schema": "shop.orders", "returning": ["id"],
  "rows": [ {"id": "ord-1900"} ], "rows_affected": 1 }

Every write statement, and what it does

Statement Status Notes
INSERT … VALUES (…) executes Multi-row VALUES supported. The column list is mandatory. A duplicate primary key is a 409, not an overwrite.
INSERT … SELECT … executes Full source-scan authorization applies.
INSERT … ON CONFLICT executes DO NOTHING and DO UPDATE SET col = literal | EXCLUDED.col. A conflict target is required; primary-key columns cannot be SET.
INSERT … RETURNING executes Returns the written rows projected to the listed columns.
UPDATE … SET … WHERE … executes WHERE is mandatory. Primary-key columns cannot be SET. UPDATE … FROM and joined UPDATE are refused.
UPDATE … RETURNING executes Returns the updated rows with their NEW (post-SET) values.
DELETE FROM … WHERE … executes Both the primary-key fast path and an arbitrary predicate.
DELETE … RETURNING executes Returns the deleted rows.
DELETE FROM t (no WHERE) executes Deletes every row. Accepted only on this endpoint — every other surface refuses a bare DELETE.
CREATE TABLE · CREATE INDEX · CREATE VIEW · CREATE SEQUENCE · CREATE SCHEMA executes CREATE INDEX backfills existing rows before it returns.
ALTER TABLE ADD / DROP / RENAME COLUMN · ADD / DROP CONSTRAINT executes Driven to completion synchronously — the change is live when the response returns.
DROP TABLE · DROP VIEW · DROP SEQUENCE · DROP SCHEMA executes DROP TABLE is a full destructive purge: rows, indexes, relations and the registration. DROP SCHEMA is RESTRICT by default — it refuses while the namespace still owns tables; CASCADE drops every table in it.
CREATE / DROP PROCEDURE · FUNCTION · CALL executes Preview scope — a single statement body for procedures, a scalar expression for functions.
BEGIN · COMMIT · ROLLBACK executes Buffers writes into a session transaction. See the transactions page.
Anything else refused Returns 400 listing the accepted statement verbs.
a bare DELETE deletes everything, and this endpoint allows it

DELETE FROM shop.orders with no WHERE is accepted here and removes every row. Every other surface refuses it. UPDATE is the opposite — it requires a WHERE as a safety check. Do not rely on the asymmetry; put a predicate on both.

SQL INSERT rejects duplicates — the row endpoint does not

An INSERT whose primary key already exists returns 409 and writes nothing; the existing row survives. That check covers both committed rows and duplicates inside the same VALUES list. The row endpoint is an upsert and overwrites instead — a real difference between the two write paths, worth knowing before you pick one.

use the row batch endpoint for bulk loading

Multi-row INSERT … VALUES works, but the batch row endpoint is the fast path by a wide margin, and it has a streaming form with no size limit. Reserve SQL INSERT for writes where you want the strict duplicate check or a RETURNING clause.

Stored procedures and functions

Preview scope. A procedure wraps one write statement whose parameters bind as $1..$N; run it with CALL. A function returns one value from a scalar expression and is usable inside a query. CREATE OR REPLACE is not supported - drop and recreate to change a definition.

-- A procedure: one statement, parameters bound as $1..$N.
CREATE PROCEDURE add_customer(id TEXT, email TEXT) AS BEGIN
  INSERT INTO shop.customers (id, email) VALUES ($1, $2)
END;

-- Invoke it with literal arguments.
CALL add_customer('c_501', 'ada@example.com');

-- A scalar function returns one value from an expression.
CREATE FUNCTION shout(s TEXT) RETURNS TEXT RETURN UPPER(s);

-- Use it inside a query like any built-in.
SELECT id, shout(email) FROM shop.customers;

-- Change one by dropping and recreating (no CREATE OR REPLACE).
DROP PROCEDURE add_customer;
DROP FUNCTION shout;

The procedure body is a single SELECT / INSERT / UPDATE / DELETE / CALL. Multi-statement bodies, procedural control flow, and the USING / DETERMINISTIC / REMOTE clauses are refused in preview. A function can also return a set with RETURNS TABLE(...).

13

EXPLAIN.

Prefix any SELECT with EXPLAIN to get the plan back instead of the rows. The one thing to look for is the leaf: an index scan or index range scan means your predicate is using an index; a plain scan under a filter means it is reading the whole table.

EXPLAIN and EXPLAIN ANALYZE
# Check whether an index is actually being used.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{"sql":"EXPLAIN SELECT id FROM shop.orders WHERE status = '\''paid'\''"}'

# EXPLAIN ANALYZE runs the query and adds per-operator timings.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
  -d '{"sql":"EXPLAIN ANALYZE SELECT id FROM shop.orders WHERE status = '\''paid'\''"}'
response
{ "kind": "explain", "plan": "IndexScan shop.orders by_status ..." }
response
{ "kind": "explain", "plan": "...", "stats": { ... } }

Predicate pushdown follows a fixed order: an equality on a single-column indexed column becomes an index scan; a >, < or BETWEEN on one becomes an index range scan; anything else becomes a full scan with a row-by-row filter. Predicate terms the index cannot serve are re-applied above it.

14

Where GROUP BY, JOIN and HAVING run.

Projections, filters, sorting, limits, subqueries, set operations and plain aggregates all run on every configuration. GROUP BY, any JOIN and HAVING are bundled into every paid configuration from Thunder up, so there is no separate add-on to buy.

On a configuration that does not include it, the request comes back 402 with a structured body naming what to enable:

{
  "error":        "addon_required",
  "addon":        "sql-pro",
  "name":         "SQL Pro",
  "purchase_url": "https://app.originchain.ai/billing/addons?enable=sql-pro",
  "msg":          "This endpoint requires the SQL Pro add-on. Enable it at
                   /app/billing/addons or have an admin do so."
}

Note that SELECT COUNT(*) FROM shop.orders is not gated — it is an aggregate without a GROUP BY. If you hit this on a configuration that should include it, check Billing → Add-ons in the console.

the gate reads the statement text, not the parse tree

It is a whole-word, case-insensitive scan for GROUP, JOIN, HAVING, LEFT, RIGHT, FULL and OUTER, and it runs before the statement is parsed — so it does not know a string literal from a keyword. WHERE name = 'GROUP' triggers it, and so does a column or table called left, outer or join. If you get an unexpected 402 on a query with no join in it, that is why: bind the literal as a parameter, or rename the column.

15

Limits & gotchas.

the big one — a projection over a large table can fail with 413

Results are capped per query, by row count and by size. Past the cap the request fails outright:

413
{ "error": "result_too_large", "unit": "rows", "observed": 240000,
  "cap": 200000,
  "msg": "The query's result set exceeds the engine's per-query memory
          cap. Add a LIMIT, narrow the filter, or page the ..." }

The cap scales with the instance's memory — a few hundred thousand rows or a few hundred megabytes, whichever binds first. It is enforced during execution, so an unfiltered scan aborts partway rather than running to completion and then failing. Always put a LIMIT on an exploratory projection, and page large exports with OFFSET.

ORDER BY … LIMIT is not a top-N stream

Filters, projections and limits over a plain scan stream row by row, and so do aggregates. A sort does not — it materialises its whole input first, and so do joins, set operations and window functions. That means SELECT … ORDER BY x LIMIT 10 over a very large table buffers the entire table before it takes ten rows, and can hit the cap above even though you asked for ten rows. Narrow it with a WHERE on an indexed column first.

Limit Value Notes
Request body 8 MiB A statement larger than this is a 413. Bind parameters rather than inlining a large literal list.
Statements per request 1 Semicolon-separated batches are refused.
Tables per FROM 32 Over the cap the statement is refused with a message naming the limit.
Subquery nesting 1 level A subquery inside a subquery is refused.
Recursive CTE depth 100 The iteration cap on WITH RECURSIVE.
Concurrent heavy queries scales with memory Over the limit a query waits briefly, then sheds with 429 and a Retry-After.
Cross-shard result gather 1,000,000 rows On a sharded instance only. Over it, the query is refused with 501 — narrow it.

Smaller edges worth knowing

  • SELECT 1 fails. Every statement that names a column must name a table. Drivers and connection pools that probe liveness with a bare SELECT 1 will get an error — point them at a real table, or use the health endpoint. A constant-only expression such as SELECT 1 + 2 AS three does work.
  • No NULLS FIRST / NULLS LAST. Missing values sort as JSON null. If null placement matters, add a CASE expression to the projection and sort on that.
  • ORDER BY takes bare column names. Not ORDER BY LOWER(name), not ORDER BY a + b. Project the expression with an alias and order by that alias. A projection position works too, except over SELECT *.
  • No derived tables. FROM (SELECT …) t is refused — use a WITH clause, which is supported.
  • NOT BETWEEN is refused. Write col < a OR col > b — the error message on this one contains stale advice claiming OR is unavailable; it is available.
  • A CTE's columns are not validated. Referring to a column the CTE does not actually produce is accepted and yields nulls rather than an error.
  • OFFSET without LIMIT disables limit pushdown into an index range scan. Pair them when you can.
  • Inside a session transaction, statements do not see the buffer. A SELECT after a buffered INSERT will not find the new row. See transactions.