SQL & Postgres β Reference
Lookup material for the SQL subset. For prose and examples see the Guide; to get started, the Quickstart.
Aito's SQL is a projection of the v2 JSON query language, not a separate
engine: a statement is parsed, lowered to the same query JSON the
_query endpoint accepts, and run by the same engine.
That is why the correspondence table below is exact rather than
approximate, and why an operator with no SQL spelling is a missing spelling,
not a missing capability β the same query is always available as JSON.
Statements
Synopses of every statement the parser accepts. Anything else is rejected with a
SQLSTATE, never silently ignored. SELECT runs on both the REST endpoint and the
wire protocol; every write statement is wire-protocol only β /api/v2/_sql
is read-only.
SELECT
SELECT <expr> [AS alias] [, β¦]
FROM <table>
[ INNER JOIN <table2> ON <table>.<fk> = <table2>.<pk> ]
[ WHERE <condition> ]
[ GROUP BY <column> ]
[ HAVING <condition> ]
[ ORDER BY <key> [ASC | DESC]
| ORDER BY <vector-column> <-> '[<v1>, <v2>, β¦]' ]
[ LIMIT <n> ] [ OFFSET <n> ]
One table plus at most one INNER JOIN, which must follow a declared
link. GROUP BY takes a single column and ORDER BY a
single key. A bare SELECT with no LIMIT returns all rows (not a default
page), matching Postgres.
Text search β the tsquery constructors
Postgres's four constructors mean four different things, and Aito honours the difference:
| written | means | lowers to |
|---|---|---|
plainto_tsquery('red shoe') | both terms, anywhere | $match |
phraseto_tsquery('red shoe') | the terms adjacent | $search "red shoe" |
to_tsquery('cat & !dog') | operators: & and, | or, ! not | $search |
websearch_to_tsquery('"a b" or c -d') | what a search box accepts | $search |
to_tsquery's operators are translated (& β juxtaposition, | β OR,
! β negation). websearch_to_tsquery's syntax is already Aito's β quoted
phrases, -term, juxtaposition for AND β apart from the lowercase word or,
which is rewritten outside quotes only, so the phrase "cat or dog" keeps
its or.
Refused rather than dropped: to_tsquery's <-> adjacency operator (write
phraseto_tsquery('a b') or a "quoted phrase") and its weights/prefixes
(term:*, term:A).
This changed. All four constructors previously lowered to $match on
whatever string they wrapped, so to_tsquery('cat & !dog') tokenised to
[cat, dog] and required both β returning exactly the rows the query asked to
exclude. If you relied on the old behaviour, plainto_tsquery is the one that
means "all of these terms".
Text search with highlighting β aito.search
SELECT id, name, highlight FROM aito.search('products', 'name', 'milk') LIMIT 5;
The search query type: a text match ranked by relevance. It is what hosts the
three explanation projections, which are refused on an ordinary row query β
so col @@ 'text' (a plain filter) cannot produce them, and this function can:
| column | what it is |
|---|---|
highlight | the matched value with the query's tokens tagged |
matches | the matched tokens with their positions |
why | the relevance factor tree |
You ask for them by selecting them, not by a flag β SELECT * returns the
plain columns and computes no projection. (The engine has no wildcard in a
select list, so the columns have to be named anyway; naming what you want is
what SQL already does.)
Similar rows β aito.knn / aito.nn
Rank rows by feature similarity to an exemplar (text fields score by BM25):
SELECT id FROM msgs WHERE aito.knn('text', 'sim card') LIMIT 5;
SELECT id FROM msgs WHERE aito.nn('text', 'sim card', threshold => 0.5);
This is a condition spelled as a function, and it mirrors the JSON operator
directly β $knn takes near: {column: exemplar}, so the SQL takes the column
and the exemplar. It is Aito-specific, so it lives in the aito schema.
Not to be confused with <->. The vector search operators
measure distance on an embedding column; these measure feature similarity to an
example row's value, and need no vector column.
LIMIT pages it, not k. In the JSON operator k applies only on a predict
query, where the k nearest neighbours vote; on a row query it is ignored. Writing
k => 2 here is therefore refused rather than silently doing nothing β use
LIMIT.
Vector search β ORDER BY col <-> '[β¦]'
Nearest-neighbour search is spelled the way pgvector spells it, so a client that already emits it needs no change:
SELECT id, name FROM products
WHERE category = 'shoes'
ORDER BY embedding <=> '[0.12, -0.03, 0.88]'
LIMIT 10;
All three pgvector distance operators are accepted β <-> (L2), <=> (cosine),
<#> (inner product) β and against an Aito vector column they order
identically. A cosine column's vectors are unit-normalised on ingest, and for
unit vectors L2 distance is a monotone function of cosine similarity, so the
ranking is the same whichever you write. The metric itself comes from the
column's own similarity setting, not from the operator: the operator picks the
spelling your client knows, not the maths. A '[β¦]'::vector cast is accepted and
ignored, since the type comes from the column.
WHERE filters the candidates, not the results. This is the part worth
being precise about: the filter is applied before the search, so LIMIT 10
means "the 10 nearest among the shoes". A database that filters afterwards
answers "the 10 nearest overall, then whichever are shoes" β which quietly
returns fewer than 10, or none at all.
ORDER BY β¦ DESC is refused: it asks for the farthest rows, and
nearest-neighbour search has no such reading. Ordering by a vector distance
cannot be combined with another sort key.
Still JSON-only: a score floor (having/threshold), selecting the similarity
score as a column, and the vector-as-evidence operators for prediction. See
Vector search for those.
INSERT
INSERT INTO <table> [ (<column> [, β¦]) ] VALUES (<value> [, β¦]) [, (β¦) β¦]
[ ON CONFLICT [ (<column>) ] DO NOTHING
| ON CONFLICT [ (<column>) ] DO UPDATE SET <column> = <value> [, β¦] ]
A conflict is a duplicate primary key; without an ON CONFLICT clause it
raises 23505.
UPDATE / DELETE
UPDATE <table> SET <column> = <value> [, β¦] [ WHERE <condition> ]
DELETE FROM <table> [ WHERE <condition> ]
Omitting WHERE affects every row, as in Postgres.
CREATE TABLE
CREATE TABLE [ IF NOT EXISTS ] <table> (
<column> <type> [ NOT NULL | NULL ] [ PRIMARY KEY ]
[ COLLATE "<analyzer>" ]
[ REFERENCES <table2> (<column2>) ]
[ , β¦ ]
[ , PRIMARY KEY (<column>) ]
[ , FOREIGN KEY (<column>) REFERENCES <table2> (<column2>) ]
)
The column clauses are not decoration β they declare the Aito data model.
COLLATE picks the text analyzer and REFERENCES creates a real
link that JOIN, predict and recommend can navigate.
See Data types.
ALTER TABLE / DROP
ALTER TABLE <table> ADD [COLUMN] [IF NOT EXISTS] <column> <type> [NOT NULL | NULL]
ALTER TABLE <table> DROP [COLUMN] [IF EXISTS] <column>
DROP TABLE [IF EXISTS] <table>
DROP VIEW [IF EXISTS] <view>
CREATE VIEW
CREATE [OR REPLACE] VIEW <view> AS
SELECT <columns | *> FROM <source> [ WHERE <equality> [AND β¦] ]
A view is a materialised, from-able collection kept current by incremental
refresh β not stored SELECT text. See Views for the v1
limits (single source; equality-only baked WHERE).
COPY
COPY <table> [ (<column> [, β¦]) ] FROM STDIN
[ WITH ( FORMAT csv|text|binary
, DELIMITER '<c>' , NULL '<s>' , QUOTE '<c>' , HEADER ) ]
Bulk load over the wire protocol β the fast path for large ingests.
COPY β¦ TO (export) is not supported and is refused explicitly.
Session, transaction & probe statements
Transactions are real. BEGIN / START TRANSACTION, COMMIT / END and
ROLLBACK / ABORT do what they say: writes inside a block are buffered, and
COMMIT applies them together. ROLLBACK discards them.
BEGIN;
INSERT INTO items (id, name) VALUES (1, 'a');
INSERT INTO items (id, name) VALUES (2, 'b');
COMMIT; -- both rows land together, or neither
- Read-your-writes. Inside a block, queries see the block's own uncommitted
writes on top of the state at
BEGIN. COMMITmerges; it does not clobber. The buffered writes are replayed onto the current state, so a row written by someone else between yourBEGINandCOMMITsurvives.- A failed statement poisons the block with SQLSTATE
25P02, and every later statement is refused untilCOMMITorROLLBACKβ as in Postgres.COMMITof a poisoned block rolls back. - Isolation is roughly READ COMMITTED. Independent rows merge cleanly; a read-modify-write racing a concurrent change to the same row is last-writer-wins per row, so write skew is possible.
Savepoints work for partial rollback:
BEGIN;
INSERT INTO items (id, name) VALUES (1, 'kept');
SAVEPOINT s1;
INSERT INTO items (id, name) VALUES (2, 'undone');
ROLLBACK TO SAVEPOINT s1; -- row 2 dropped, row 1 still pending
COMMIT; -- row 1 lands
Rolling back to a savepoint also recovers a poisoned block, so a failed
statement need not lose the whole transaction. RELEASE [SAVEPOINT] s forgets a
savepoint while keeping its writes; rolling back to a savepoint that does not
exist is an error (3B001), never a silent full rollback.
SET is still accepted and ignored β including SET search_path, so the
aito. schema cannot be put on a search path; qualify
explicitly when you need the qualified form. SHOW is likewise answered for
driver compatibility.
Requires: a build newer than v2.6.0 for DISCARD ALL to actually
discard; on v2.5.3 and earlier it returned its command tag and kept everything.
DISCARD ALL really discards. It drops this connection's prepared
statements, portals and open cursors, and clears any pending transaction state β
which matters because a connection pooler runs it between users and reads the
returned tag as proof the connection is clean. Cursors in particular hold a
materialised result set, so leaving them would leave the previous user's rows
readable by name. As in Postgres it is refused inside a transaction block
(25001). DISCARD PLANS / SEQUENCES / TEMP are accepted and genuinely do
nothing β there is no plan cache, there are no sequences, and there are no temp
tables.
Cursors (DECLARE / FETCH) are read-only and materialise the whole result up
front.
A batch is one implicit transaction, as in Postgres: if a later statement
fails, the earlier ones do not stand. This holds on both protocols β a
multi-statement simple query, and an extended-protocol sequence up to its Sync
(which is what most drivers, including pgjdbc, actually send, because they split
a multi-statement string client-side). A string carrying its own
BEGIN/COMMIT is the client managing the block and runs as written, and a
single statement still commits on its own.
Data types
CREATE TABLE maps Postgres types onto Aito's data model. The mapping is the
declaration β the type you choose decides how a column can be queried and how
inference treats it.
| Postgres type | Aito type | Meaning |
|---|---|---|
int, integer, int4, smallint, int2 | Int | integer |
bigint, int8 | Long | 64-bit integer |
numeric, decimal, real, float, float4, float8, double precision | Decimal | fractional |
boolean, bool | Boolean | true/false |
date | Date | calendar date |
timestamp, timestamptz, timestamp with[out] time zone | Timestamp | a point in time, normalised to UTC on write (see Timestamps) |
varchar, character varying, char, character, bpchar | String | categorical: matched whole, exactly |
text | Text (analysed) | full-text: tokenised, searchable with @@ |
vector(n) | Vector | an n-dimensional embedding, searched with <-> |
Any other type β uuid, json, β¦ β is rejected at
CREATE TABLE with a message listing what is supported; nothing is silently
coerced. A column is nullable unless it is declared NOT NULL or is part of
the primary key.
text vs varchar β the choice that matters
This is the one mapping worth deliberate thought, and it is deliberately not Postgres's distinction (where both are the same type with a length check):
varcharis a category. Whole values are compared, so it is what you want for a status, a country code, a product id β and whatpredictlearns to output.textis analysed. Values are tokenised, so@@full-text search works over them and inference reasons about individual words β what you want for a description, a note, a subject line.
Getting it wrong is silent at write time and disappointing at query time: @@
on a varchar column has no tokens to match.
vector(n) β embeddings
CREATE TABLE docs (id varchar PRIMARY KEY, embedding vector(384));
INSERT INTO docs (id, embedding) VALUES ('a', '[0.1, 0.2, β¦]');
The dimension is part of the type, not a display width like varchar(255)
β a column of unknown width could not validate what is written to it β so a bare
vector is refused. Values are written with pgvector's literal syntax, and a
value of the wrong width is refused at ingest rather than padded or truncated.
Search them with ORDER BY β¦ <-> '[β¦]'.
COLLATE β choosing the analyzer, or the vector metric
description text COLLATE "english"
The collation names the text analyzer (english, finnish, β¦ ) rather than a
sort order β so stemming and stop-words match the language of the content.
On a vector column it names the similarity metric β cosine (the default) or
dotProduct:
embedding vector(384) COLLATE \"dotProduct\"
That is not a stretch of the word: a collation is the rule by which a column's values are compared, and for a vector column the comparison rule is exactly the metric. An unknown analyzer or metric is refused, naming the ones that exist.
REFERENCES β declaring a link
product_id varchar REFERENCES products(id)
Creates an Aito link, which is what makes INNER JOIN β¦ ON work, and what
lets predict/recommend reason across tables. A foreign key here is a
navigable relationship, not just a constraint.
Timestamps
A timestamp column stores a point in time, normalised to UTC on write.
That normalisation is the point: an offset-bearing literal is converted, not
stored verbatim, so '2026-08-21T09:00:00+03:00' and '2026-08-21T06:00:00Z'
are the same stored instant and can never sort or range-filter against each
other incorrectly. An unparseable value is rejected, never stored wrong. Values
are kept at millisecond precision, and the column reports the timestamptz OID.
CREATE TABLE events (id integer PRIMARY KEY, ts timestamp);
SELECT id FROM events WHERE ts > '2026-08-21T09:30:00+03:00'; -- any offset works
now() and intervals. now() / current_timestamp may be used in a
WHERE, with interval arithmetic:
SELECT id FROM events WHERE ts > now() - interval '30 days';
SELECT id FROM events WHERE ts BETWEEN now() - interval '2 weeks' AND now();
SELECT id FROM events WHERE ts < now() - interval '1 year 2 months';
now() is a constant for the whole statement (Postgres semantics) and the
interval folds at parse time, so the query stays a pure function of the data and
the submission time. Units: year, month, week, day, hour, minute,
second (singular or plural, and compound as above). Month and year arithmetic
is calendar-correct. An unknown unit is refused.
Buckets and parts. date_trunc(unit, ts) truncates down to a unit and
returns a timestamp; EXTRACT(field FROM ts) reads a single component:
SELECT date_trunc('month', ts) AS m FROM events; -- every August instant β 2026-08-01T00:00:00Z
SELECT id FROM events WHERE EXTRACT(HOUR FROM ts) = 9;
date_trunc units are year, quarter, month, week (ISO, Monday), day,
hour, minute, second. EXTRACT fields include YEAR, QUARTER, MONTH,
DAY, DOY, DOW/ISODOW, HOUR, MINUTE.
Time zone. Aito computes date_trunc and EXTRACT in UTC, whereas
Postgres computes them in the session TimeZone. Connecting with a non-UTC
TimeZone therefore gives different buckets than Postgres would for the same
query. Aito stores an absolute instant; the UTC rendering is the stable one.
Derived columns. Declaring ts timestamp also materialises its derived
parts as real, queryable columns β ts.year, ts.quarter, ts.month,
ts.weekOfMonth, ts.day, ts.weekday, ts.dayOfWeek, ts.hour,
ts.minute, ts.dayOfYear. They appear in the schema, can be selected and
filtered like any column, and β the reason they exist β predict and
relate use them as features and targets, so cyclic structure ("orders
spike on Saturday afternoons", "this job fails Saturday night") is learned out
of the box instead of hand-rolled:
SELECT id FROM events WHERE "ts.weekday" = 6; -- Saturdays (ISO 1=Mon β¦ 7=Sun)
SELECT predict(category) FROM events WHERE "ts.hour" = 9;
The timestamp is authoritative: the derived cells are always recomputed from
ts, so they cannot drift out of step with it.
Operator & function quick reference
Everything the subset accepts, at a glance. Anything not listed is rejected
loudly (with SQLSTATE 0A000 / 42601), never silently ignored.
| Category | Supported |
|---|---|
| Comparison | =, <> / !=, <, <=, >, >= |
| Set / range | IN (β¦), NOT IN (β¦), BETWEEN a AND b |
| Null | IS NULL, IS NOT NULL (= NULL rejected β use IS NULL) |
| Text match | LIKE 'ap%' (prefix) or exact, NOT LIKE β no %ap, %ap%, _, or ILIKE |
| Full-text | col @@ 'text' / plainto_tsquery('text') β all-tokens $match; to_tsquery, websearch_to_tsquery, phraseto_tsquery β $search (phrases, OR, negation). See below and the Guide |
| Boolean logic | AND, OR, NOT, parentheses |
| Arithmetic | +, -, *, /, ^ (power, binds tighter than *), parentheses, precedence |
| Date parts | EXTRACT(YEAR|MONTH|DAY|DOY|DOW|ISODOW FROM col) = n, date_part('year', col) = n β equality only |
| Date ranges | col > '2026-03-01', col BETWEEN '2026-01-01' AND '2026-03-31' on a date column β < <= > >=, compared in calendar order. The bound is an ISO yyyy-MM-dd string; a malformed one is refused rather than matching nothing. See below |
| Modulo | col % d = r β read as one condition, so the comparison must be = |
| String fns | || (concat), concat, upper, lower, length |
| Array fns | cardinality(col), array_length(col, 1) β the element count of a stored set (distinct from length(str)) |
| Scalar fns | coalesce(a, b, β¦), cast(x AS int|bigint|text|numeric|β¦) |
| Aggregates | count(*), count(DISTINCT c), sum, avg, min, max β usable inside expressions |
| Prediction | predict(col) (argmax), predictions(col) β‘ predict(col, probabilities => true) (distribution); PREDICTION/PREDICTIONS aliases β inference in SQL; not with GROUP BY. One inference per returned row, so an absent LIMIT defaults to 10 (a plain SELECT without a prediction column stays unbounded). See the Guide |
| Similar rows | WHERE aito.knn('col', <exemplar>) ranks rows by feature similarity (page with LIMIT); aito.nn('col', <exemplar>, threshold => t) keeps those above a score. See below |
| Search | SELECT id, highlight FROM aito.search('t','col','text', k => N) β text match ranked by relevance; highlight / matches / why are columns you SELECT. startSel / stopSel set the markup. See below |
| Prediction, hypothetical | SELECT * FROM predict('from','target', given => 'β¦') (argmax) Β· SELECT * FROM predictions('from','target', given => 'β¦', k => N) (distribution) β evidence is stated, so it answers for a row that need not exist β (value, p[, why]). See below |
| Match | SELECT * FROM match('from','target', basedOn => 'β¦', given => 'β¦', k => N) β the v1 name for FROM predictions(...); identical query β (value, p[, why]) |
| Recommend | SELECT * FROM recommend('from','target', goal => 'β¦', given => 'β¦', why => true, k => N) β set-returning function β (value, p[, why]); why => true adds the explanation factor tree. See the Guide |
| Relate | SELECT * FROM relate('from', fields => 'β¦', to => 'β¦', k => N) β set-returning function β (related, lift, info, n). See the Guide |
| Clauses | WHERE, GROUP BY (single col), HAVING, ORDER BY β¦ ASC|DESC (single key), LIMIT, OFFSET |
| Join | INNER JOIN β¦ ON <base>.<fk> = <joined>.<pk>, along a declared link |
Not supported yet (rejected with a clear error, never silently ignored):
multiple ORDER BY keys, outer/cross/multi-table JOIN (and joins whose ON
is not a declared link), multi-column GROUP BY, per-group min/max,
UNION, substring/suffix LIKE and ILIKE, subqueries, and
schema-qualified table names (public.t). Double-quoted identifiers
are supported, and Aito's own functions accept the aito. schema. = NULL / <> NULL are rejected with a hint
to use IS NULL / IS NOT NULL (matching Postgres's three-valued logic).
(INSERT, CREATE TABLE, and CREATE VIEW are supported over the Postgres
wire protocol β see Writes β but not on the
read-only /api/v2/_sql endpoint.)
Aito function signatures
What each of Aito's own functions takes and produces. Generated from the same declaration the parser builds its argument errors from, so an error message and this table cannot disagree.
<!-- BEGIN GENERATED: sql-function-signatures -->| function | takes | does |
|---|---|---|
predict | aito.predict(<column>[, probabilities => true|false]) | the most probable value of a column, per row |
prediction | aito.prediction(<column>[, probabilities => true|false]) | alias of predict |
predictions | aito.predictions(<column>[, probabilities => true|false]) | the full predicted distribution, per row |
predict | FROM aito.predict('<table>', '<column>'[, given => '<condition>'][, basedOn => '<column>[, β¦]'][, why => true|false][, k => <int>]) β (value, p, why) | the most probable value of a column given hypothetical evidence |
predictions | FROM aito.predictions('<table>', '<column>'[, given => '<condition>'][, basedOn => '<column>[, β¦]'][, why => true|false][, k => <int>]) β (value, p, why) | the predicted distribution given hypothetical evidence |
recommend | FROM aito.recommend('<table>', '<column>', goal => '<condition>'[, given => '<condition>'][, why => true|false][, k => <int>]) β (value, p, why) | the values of a target column that best serve a goal |
search | FROM aito.search('<table>', '<column>', <value>[, startSel => <value>][, stopSel => <value>][, k => <int>]) β (highlight, matches, why) | rows matching a text query, ranked by relevance |
match | FROM aito.match('<table>', '<column>'[, given => '<condition>'][, basedOn => '<column>[, β¦]'][, why => true|false][, k => <int>]) β (value, p, why) | candidate values of a field, ranked by calibrated probability (the v1 name for FROM predictions(...)) |
knn | aito.knn('<column>', <value>) | rows most similar to an exemplar, by feature similarity |
nn | aito.nn('<column>', <value>[, threshold => <number>]) | rows scoring above a similarity threshold to an exemplar |
relate | FROM aito.relate('<table>', to => '<condition>'[, fields => '<column>[, β¦]'][, k => <int>]) β (related, lift, info, n) | what relates to a condition, by lift |
patterns | FROM aito.patterns('<table>', fields => '<column>[, β¦]'[, where => '<condition>'][, k => <int>]) β (related, condition, lift, n) | value co-occurrence patterns mined over some columns |
cluster | aito.cluster('<column>', <value>, k => <int>) | quantise a vector column into a categorical predict code (k-means) |
semantic | aito.semantic('<column>', <value>[, k => <int>][, weight => <number>]) | vector kNN late-fusion: blend the k nearest neighbours' votes into a predict |
Date ranges
Requires: a build newer than v2.6.0.
A date column compares in calendar order:
SELECT id FROM orders WHERE placed > '2026-03-01';
SELECT id FROM orders WHERE placed >= '2026-01-01' AND placed <= '2026-03-31';
Before this, a date was queryable by extracted parts (EXTRACT(MONTH FROM placed) = 1) and by equality, but not by range β so a calendar-aligned bucket
was expressible and an arbitrary window was not. "Last quarter", "since the
incident", "the 90 days after each install" and a train/test split by time all
need a range.
The bound is an ISO yyyy-MM-dd string. A malformed bound is refused, not
treated as "no rows":
SELECT id FROM orders WHERE placed > 'last march';
-- ERROR: $gt on a DateType column expects an ISO date (yyyy-MM-dd), got 'last march'
Ranges on a timestamp column work the same way and additionally accept a full
ISO-8601 instant with an offset β see Timestamps.
The two predicts
Requires: a build newer than v2.6.0 for the FROM predict(...) /
FROM predictions(...) form. The per-row predict(col) projection has
shipped since v2.
predict names two different operations. Which one you get is decided by
where you write the call, and the difference only shows up when nothing in the
table matches your evidence.
Per row β predict(col) in the SELECT list. Evidence is the row itself plus
the query's WHERE. It annotates the rows the query returns:
SELECT id, predict(category) FROM invoices
WHERE vendor = 'Acme' AND amount = 150;
-- one row out per matching invoice
Hypothetical β FROM predict('t','col', given => β¦). Evidence is stated
in given rather than read off a row, so there is nothing to match and it
always returns an answer:
SELECT * FROM predict('invoices', 'category',
given => 'vendor = ''Acme'' AND amount = 42');
-- value | p
-- hardware | 0.4691
The distinction matters because these behave differently in exactly the case you are most likely to care about β an invoice you have not seen:
| no matching row | what it is for | |
|---|---|---|
SELECT predict(col) β¦ WHERE β¦ | 0 rows | filling in a column across rows you already have |
FROM predict('t','col', given => β¦) | still answers | scoring a hypothetical, one answer |
predict(...) is the argmax and returns one row; predictions(...) is the same
query returning the ranked distribution (k defaults to 10). Add
why => true to either for the explanation factor tree, as on recommend.
match(...) is the v1 name for predictions(...) and runs the identical query.
Highlight markup β startSel / stopSel
Requires: a build newer than v2.6.0 for the tag arguments. The
highlight / matches / why columns themselves ship in v2.6.0.
highlight returns the matched field with the query's tokens marked. By default
the engine picks the markup; startSel and stopSel set it:
SELECT id, highlight
FROM aito.search('products', 'description', 'red shoe',
startSel => '<mark>', stopSel => '</mark>');
Either may be given alone; the other keeps its default. These are Postgres's own
ts_headline option names, taken as named arguments rather than as its options
string ('StartSel=<b>, StopSel=</b>') β a string would be a second grammar
inside a literal, with its own parse errors, which nothing could check for you.
ts_headline(...) itself is not implemented, and is deliberately left
unclaimed rather than bound to something that behaves differently. It is a
scalar over a document and a tsquery; the natural home for it is a plain
WHERE col @@ 'text' query rather than the search(...) table function, which
already knows both.
The negative tags (negPreTag / negPostTag) have no SQL spelling. They mark
evidence against a candidate, which is a $why concept β a search hit's
tokens either matched or are not there. They remain available in the JSON
{ "$highlight": { β¦ } } form.
Function names and the aito schema
Aito's own functions β predict, predictions, recommend, relate β answer
to two spellings:
SELECT aito.predict(GLCode) FROM invoices; -- canonical
SELECT predict(GLCode) FROM invoices; -- alias, while the name is free
The qualified form is canonical. The bare name is an alias offered only while
nothing else claims it β which can never be a guarantee, because whether a name
is taken depends on what your client has installed: CREATE EXTENSION pg_trgm is one statement away from defining similarity(). Schema-qualifying
is Postgres's own answer to this, the way pg_trgm, PostGIS and MADlib coexist.
Use bare names interactively; prefer aito.-qualified names in saved queries,
views and BI tools, where a later extension install should not change what a
query means. (SET search_path is a no-op, so qualify explicitly.)
Postgres's own functions are not in this schema. coalesce, cast, upper,
lower, length, concat and the aggregates are Postgres functions that Aito
implements faithfully β sharing the name is the compatibility promise β so
they are called unqualified, and aito.coalesce(β¦) is an error.
A dot still means a link path. orders.customer.name navigates a
link, as always; only a name directly followed by ( is
read as a possibly-qualified function.
Result column names
Requires: a build newer than v2.6.0. On v2.6.0, /api/v2/_sql answers
an aggregate under the engine token ($sum:price, $count) and the ML table
functions under $value / $p, while a pgwire client sees the SQL names β so
switching transport renamed your columns. If you are on the released build,
read the engine-token spelling in the right-hand column below.
A SQL query answers under SQL column names, on both transports. SELECT sum(price) returns a column called sum whether you read it over
/api/v2/_sql or over the PostgreSQL wire protocol; SELECT * FROM recommend(...) returns value and p; AS renames as you would expect.
| SQL | column(s) | the JSON query's own key |
|---|---|---|
count(*) | count | $count |
sum(price) (and avg/min/max) | sum | $sum:price |
sum(price) AS total | total | $sum:price |
SELECT * FROM recommend(β¦) | value, p | $value, $p |
SELECT * FROM predict(β¦) / predictions(β¦) | value, p (why) | $value, $p |
SELECT * FROM relate(β¦) | related, lift, info, n | same |
SELECT id, highlight FROM aito.search(β¦) | id, highlight | $highlight |
SELECT * on a table (pgwire) | its declared columns, in declared order | β |
The last row matters for anything that introspects over the wire protocol:
SELECT * names the columns the SCHEMA declares, so a column that is unset on
some rows still appears (as NULL), and LIMIT 0 describes the full row shape
rather than returning no columns at all. Over /api/v2/_sql the answer is JSON,
where a row simply omits a key it has no value for β the same convention
_query uses, so an absent key reads as null.
The right-hand column is what the equivalent JSON query returns, and it is
unchanged: POST /api/v2/_query speaks the JSON vocabulary. Only the SQL
surfaces use SQL names.
JSON β SQL correspondence
Every v2 JSON query operator and how it is spelled in SQL. Because SQL lowers into that JSON, this table is the mapping the engine actually uses.
Three kinds of entry are worth reading carefully:
operatorβ SQL's own grammar already spells it ($gtis>), so there is no function to learn.noneβ deliberately not in SQL. The learned-ranking blends are a composable algebra of probability lifts, andORDER BYhas no syntax for it; use the JSON query API.impliedβ you never write it; a SQL construct produces it.recommend(..)yields thepcolumn,GROUP BYlowers to aget.ββ a spelling that should exist and does not yet.
Every operator is listed: declaring a SQL answer β including βnone, because β¦β β is required of each one, so this table has no silent omissions.
<!-- BEGIN GENERATED: json-sql-bindings -->| JSON operator | SQL | spelling |
|---|---|---|
$is | operator | = |
$exact | none | by design |
$has | none | by design |
$match | operator | @@ |
$search | operator | col @@ to_tsquery('a & !b') |
$gt | operator | > |
$gte | operator | >= |
$lt | operator | < |
$lte | operator | <= |
$startsWith | operator | LIKE 'prefix%' |
$mod | operator | col % d = r |
$numeric | none | by design |
$year | operator | EXTRACT(YEAR FROM col) = n |
$month | operator | EXTRACT(MONTH FROM col) = n |
$day | operator | EXTRACT(DAY FROM col) = n |
$weekday | operator | EXTRACT(ISODOW FROM col) = n |
$dayOfWeek | operator | EXTRACT(ISODOW FROM col) = n |
$dayOfYear | operator | EXTRACT(DOY FROM col) = n |
$hour | operator | EXTRACT(HOUR FROM col) = n |
$minute | operator | EXTRACT(MINUTE FROM col) = n |
$quarter | operator | EXTRACT(QUARTER FROM col) = n |
$weekOfMonth | none | by design |
$exists | operator | IS NOT NULL |
$defined | operator | IS NOT NULL |
$and | operator | AND |
$or | operator | OR |
$not | operator | NOT |
$in | operator | IN |
$on | none | by design |
$group | none | by design |
$examine | none | by design |
$nearest | operator | ORDER BY col <-> '[..]' LIMIT k |
$cluster | WHERE fn | WHERE aito.cluster(..) |
$semantic | WHERE fn | WHERE aito.semantic(..) |
$knn | WHERE fn | WHERE aito.knn(..) or bare knn(..) |
$nn | WHERE fn | WHERE aito.nn(..) or bare nn(..) |
$size | none | by design |
$tokenCount | none | by design |
$distinctCount | none | by design |
$asc | operator | ORDER BY .. ASC |
$desc | operator | ORDER BY .. DESC |
$p | implied | the p column of recommend(..) |
$f | implied | the count column of a GROUP BY; relate(..)'s frequency |
$lift | none | by design |
$context | implied | a per-group aggregate over GROUP BY |
$similarity | none | by design |
$score | none | by design |
$sameness | none | by design |
$bm25 | none | by design |
$vectorSimilarity | none | by design |
$vectorCosine | none | by design |
$vectorIdf | none | by design |
$multiply | operator | * |
$value | implied | the value column of recommend(..) / relate(..) |
$why | named arg | aito.recommend(.., why => ..) or bare recommend(.., why => ..) Β· aito.predict(.., why => ..) or bare predict(.., why => ..) Β· aito.predictions(.., why => ..) or bare predictions(.., why => ..) |
$highlight | result col | SELECT highlight FROM aito.search(..) or bare search(..) |
$matches | result col | SELECT matches FROM aito.search(..) or bare search(..) |
$sum | function | sum(..) |
$mean | function | avg(..) |
$count | function | count(..) |
$predictions | function | aito.predictions(..) or bare predictions(..) |
$prediction | function | aito.prediction(..) or bare prediction(..) |
$predict | function | aito.predict(..) or bare predict(..) |
$field | operator | a bare column reference |
$const | operator | a literal |
$subtract | operator | - |
$divide | operator | / |
$pow | operator | ^ |
$normalize | none | by design |
$length | function | cardinality(..) |
$coalesce | function | coalesce(..) |
$cast | function | cast(..) |
$upper | function | upper(..) |
$lower | function | lower(..) |
$charLength | function | length(..) |
$concat | function | concat(..) |
$dateTrunc | function | date_trunc(..) |
$feature | none | by design |
$patterns | FROM fn | FROM aito.patterns(..) or bare patterns(..) |
$related | FROM fn | FROM aito.patterns(..) or bare patterns(..) |
$sample | none | by design |
$hash | none | by design |
$index | none | by design |
$get | implied | GROUP BY col and count(DISTINCT col) both lower to a get |
Catalog & introspection
Enough of pg_catalog and information_schema is answered for Postgres tools to
browse an Aito database: pg_class, pg_attribute, pg_namespace, pg_type,
pg_database, pg_settings, pg_roles, pg_description, pg_tables and the
information_schema views behind them β so \dt, \d table, JDBC
getTables/getColumns and the equivalents in DBeaver, DuckDB and ClickHouse
work.
Two schemas are reported: public, holding the tables, and aito,
holding Aito's functions.
Requires: a build newer than v2.6.0 for function introspection; on
v2.5.3 and earlier pg_proc is empty and no Aito function appears in a tool's
completion.
Functions are introspectable. pg_proc lists Aito's functions, so \df in
psql and the function browsers in DBeaver, DataGrip and Metabase show predict,
predictions, recommend, search, match, relate, patterns, knn,
nn, cluster and semantic, each with its arguments and whether it returns a
row set. The rows are generated from the same declaration that produces the
signature table above, so a function cannot appear in
one and be missing from the other.
Catalog completion is necessarily shallower than that table: an argument that
carries a condition or a column list is a quoted string (goal => 'churn = true'), which in catalog terms is just text. Unrecognised catalog queries
return an empty result rather than an error, so an unusual tool may show nothing
rather than fail.
Warnings and notices
Requires: a build newer than v2.6.0. On v2.6.0 these notes are not delivered over either SQL transport.
Some statements succeed but carry a note worth reading β most usefully, a WHERE
on a column the collection does not declare. That condition is satisfiable by no
row, so the query correctly returns nothing; without the note, a typo is
indistinguishable from an empty table.
SELECT id FROM products WHERE colour = 'red'; -- 'color' is the column
- Over
/api/v2/_sql, the response body carries awarningsarray β{ code, message, severity, field }β alongsidehits, exactly as the JSON query API returns it. - Over pgwire, each note arrives as a PostgreSQL
NoticeResponse:psqlprints it, JDBC exposes it viaStatement.getWarnings(), psycopg viaconnection.notices. Awarningis SQLSTATE01000, an informational note00000, and the Aito code (e.g.where.unknown_field) travels in the notice's Detail field.
Notices are additive: a statement that produces none is unchanged, so a client that never reads them behaves exactly as before.
Error codes (SQLSTATE)
Failures are reported with a standard PostgreSQL SQLSTATE, so a Postgres
client or connector reacts the way it would to a real Postgres β the message is
always specific, never a swallowed empty result. Over the wire protocol the code
rides in the ErrorResponse; over REST it's in the error JSON.
| SQLSTATE | Name | When |
|---|---|---|
23505 | unique_violation | INSERT of a duplicate primary key with no (or DO NOTHING-less) ON CONFLICT |
42P07 | duplicate_table | CREATE TABLE / CREATE VIEW of a name that exists (without IF NOT EXISTS / OR REPLACE) |
42P01 | undefined_table | SELECT / INSERT / UPDATE / DELETE / ALTER / DROP on a missing table or view |
42701 | duplicate_column | ALTER TABLE ADD COLUMN of a column that exists |
42703 | undefined_column | reference to a column that doesn't exist (ALTER DROP, unknown SET/select column) |
0A000 | feature_not_supported | a valid-Postgres construct Aito doesn't implement yet (subquery, UNION, comma join, multi-key ORDER BY, LIKE with _, COPY β¦ TO, CREATE INDEX, β¦) |
42601 | syntax_error | SQL that is malformed in any dialect β a parse error, an unterminated literal, or an argument the subset rejects (= NULL, a type mismatch) |
40001 | serialization_failure | write contention β the commit lost too many CAS retries and did not happen. Retry it; this is the code ORMs, pools and dbt already retry on |
XX000 | internal_error | an unexpected server-side failure |
Requires: a build newer than v2.6.0 for the 0A000 / 42601 split
below; on v2.5.3 and earlier every capability gap reported 42601.
0A000 means narrower, 42601 means wrong. The distinction is load-bearing
for federating clients: postgres_fdw, DuckDB and SQLAlchemy fall back to
client-side execution on 0A000, but surface the query as broken on 42601. So
a construct Aito simply doesn't implement yet reports 0A000 β asking a tool to
do it locally β while 42601 is reserved for SQL that is not valid anywhere.
IF [NOT] EXISTS turns the otherwise-erroring redundant case (42P07 / 42P01 /
42701 / 42703) into a silent no-op, exactly as in Postgres.
Limitations
-
Date parts compare with
=only. Day-of-week works in both Postgres spellings: Aito stores ISO-8601 (1=Monday β¦ 7=Sunday), whichISODOWmatches exactly, soDOW(0=Sunday β¦ 6=Saturday) is converted βDOW = 0andISODOW = 7both mean Sunday. A date part in anUPDATE/DELETEWHERE, or inHAVING, is refused rather than evaluated by a second implementation that might read it differently. -
Table aliases shadow the table name, as in Postgres.
FROM orders oandFROM orders AS oboth work, on either side of a JOIN, ando.colresolves as the qualified column. Once a table is aliased the original name stops being a valid qualifier βSELECT orders.id FROM orders ois an error naming the alias to use, which is what Postgres does. (Being additive instead would accept queries Postgres rejects and teach a portable-looking habit that is not portable.) One alias cannot bind two tables. -
JOINs follow existing links only: an
INNER JOINalong a declared link works (it projects through the link β see the Guide), so a tool that sees the foreign key can run the join. Arbitrary joins on non-link columns, and outer/cross/multi-table joins, are still rejected β so apostgres_fdwremote join pushdown across two foreign tables isn't supported (join them locally, or disable join pushdown on the foreign server). -
Writes β
INSERT(withON CONFLICT),CREATE TABLE,COPY β¦ FROM STDIN(bulk load),DROP/ALTER TABLE ADD|DROP COLUMN,DELETE, andUPDATEwork over the wire protocol (see Writes); the/api/v2/_sqlREST endpoint is read-only.
Body format. /api/v2/_sql takes the statement as a raw string body,
not as JSON β there is no {"sql": "β¦"} envelope. Both
Content-Type: text/plain and application/json are accepted, so a
JSON-only HTTP client can post the statement without changing its
content-type header:
curl -s "$AITO_INSTANCE/api/v2/_sql" \
-H "x-api-key: $AITO_API_KEY" -H "Content-Type: text/plain" \
--data "SELECT id FROM products WHERE price > 10"
The response is the standard v2 result JSON, the same shape
_query returns.
- Views β
CREATE [OR REPLACE] VIEW v AS SELECT <cols / renames / *> FROM src [WHERE <equalityβ¦>]andDROP VIEW [IF EXISTS] vwork over the wire protocol (see Views). A view is a materialised, from-able collection, not stored SELECT text, soSELECT β¦ FROM vβ including a query-timeWHERE/ORDER BY/GROUP BYand evenpredict/@@β runs on it, and a queryWHEREcomposes on top of the view's baked filter. The view stays current via incremental refresh. v1 limits: a single source (noUNION/JOINin the definition), and a bakedWHEREof equality conditions only (col = v [AND β¦]); a range/IN/LIKEin a view definition, or an aggregate/GROUP BY, is rejected loud atCREATE VIEW(do it at query time against the view instead). - Inference β
predict/predictions(categorical label columns; not analysed-text/multi-value), full-text@@,recommend(...), andrelate(...)are in SQL. Still JSON-only: the raw learned-ranking blends (similarity Γ contextual$p) β those live in the JSON_queryAPI. - Metadata depth β no composite/unique/check constraints, sequences, indexes, or functions; unrecognised catalog queries return empty rather than erroring, so deep tool features may show nothing.
- Wire protocol β no SCRAM channel binding (
-PLUS) or client-certificate auth; TLS is optional, not enforced. Cursors (DECLARE/FETCH) are read-only and materialize the whole result up front (fine for the connectors above, not a streaming cursor over a huge table).
Related
- Query Reference β the JSON query language
_sqllowers to. - Relationships, Links & Joins β how a SQL JOIN maps to an Aito link.
- Environments & Auth β API keys and environments.