From SQL
The SQL you write by habit, read as the FenecQL it means — and refused, saying what to write, where it has no exact meaning here.
FenecQL is not SQL, but the everyday spellings of SQL are FenecQL too.
The parser reads CREATE TABLE, INSERT INTO …
VALUES, UPDATE … SET a = …, JOIN,
IN (…), EXISTS, DISTINCT,
LIKE and the rest of the tables below into the very
statements FenecQL writes, so a text in either reaches the engine the same
way — over HTTP, through MCP, in the
fenec shell, in the page and in an app. There is no SQL mode
and no second parser: the two spellings mix in one statement. What would
answer otherwise than SQL does — a RIGHT JOIN, an aggregate
over two tables' rows, a DEFAULT, a transaction left open — is
refused with the FenecQL that does it, so an agent writing SQL by habit
learns the language from its first error.
Every SQL text on this page is run by a test
(crates/fenec-core/tests/sql_docs.rs): the session below
statement by statement, each -- → x the answer it checks, and
each line of the tables held to the FenecQL beside it — the two parse to
the same statement — or refused.
A session written as SQL
CREATE TABLE users (id serial PRIMARY KEY, name text, email citext NOT NULL UNIQUE, age integer, city text, visits integer)
CREATE INDEX users_age ON users (age)
INSERT INTO users (name, email, age, city) VALUES ('Ada', 'ada@example.com', 36, 'London'), ('Bob', 'bob@example.com', 25, 'Leeds'), ('Cem', 'cem@example.com', 52, 'İzmir') RETURNING name -- → "Ada"
SELECT name FROM users WHERE age BETWEEN 25 AND 36 ORDER BY age DESC LIMIT 10 -- → "Ada"
SELECT name FROM users WHERE city IN ('Leeds', 'İzmir') AND name LIKE 'C%' -- → "Cem"
SELECT count(*) AS n FROM users WHERE email ILIKE '%@EXAMPLE.COM' -- → 3
UPDATE users SET visits = coalesce(visits, 0) + 1 WHERE name = 'Ada' RETURNING visits -- → 1
INSERT INTO users (email, visits) VALUES ('ADA@example.com', 1) ON CONFLICT DO UPDATE SET visits = users.visits + excluded.visits RETURNING visits -- → 2
INSERT INTO users (email) VALUES ('bob@example.com') ON CONFLICT DO NOTHING -- → 0
INSERT INTO users (email, visits) VALUES ('bob@example.com', 1) ON CONFLICT (email) DO UPDATE SET visits = excluded.visits RETURNING visits -- → 1
SELECT name || ' <' || email || '>' AS who FROM users WHERE id IN (SELECT id FROM users WHERE age > 30) ORDER BY id -- → "Ada <ada@example.com>"
SELECT name FROM users WHERE visits IS DISTINCT FROM 2 -- → "Bob"
SELECT city, count(*) AS n FROM users GROUP BY 1 ORDER BY 2 DESC, 1 -- → "Leeds"
CREATE TABLE orders (id serial PRIMARY KEY, user_id integer NOT NULL, total integer, status text)
CREATE INDEX ON orders USING hash (user_id)
INSERT INTO orders (user_id, total, status) VALUES (1, 1200, 'paid'), (1, 800, 'open'), (2, 450, 'paid') -- → 3
SELECT u.name, o.total FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' ORDER BY o.total DESC -- → "Ada"
SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id ORDER BY u.id DESC -- → "Cem"
SELECT count(*) AS n FROM orders o JOIN users u ON u.id = o.user_id WHERE u.city = 'London' -- → 2
SELECT DISTINCT u.city FROM users u JOIN orders o ON o.user_id = u.id ORDER BY u.city -- → "Leeds"
SELECT name FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) -- → "Cem"
SELECT name FROM users WHERE age = (SELECT max(age) FROM users) -- → "Cem"
DELETE FROM users WHERE age NOT BETWEEN 20 AND 40 -- → 1
CREATE TABLE docs (id serial PRIMARY KEY, body text, emb vector(3))
CREATE INDEX ON docs USING hnsw (emb vector_cosine_ops)
INSERT INTO docs (body, emb) VALUES ('north', [0, 1, 0]), ('east', [1, 0, 0]), ('up', [0, 0, 1]) -- → 3
SELECT body FROM docs ORDER BY emb <=> '[0.9, 0.1, 0]' LIMIT 1 -- → "east"email is citext, a nocase field,
so its =, its @unique and its LIKE
compare by the case fold, and 'ADA@example.com' is Ada's row.
Each join reads as the lookup it is, or as one table's rows
filtered by the other's (below); the last statement
is near, by the cosine distance the index measures.
Read as written
Each line is the SQL and the FenecQL it is — the same statement, to the last expression.
| SQL | FenecQL | Notes |
|---|---|---|
INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y') | insert t [{a: 1, b: "x"}, {a: 2, b: "y"}] | a document a row; the fields are named (below) |
INSERT INTO t (a) VALUES ($1) RETURNING id, a | insert t {a: $1} returning id, a | the rows written, the ids made among them |
INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO NOTHING | insert t {k: "a", n: 1} if absent | passed over where its id or a @unique value is held |
INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO UPDATE SET n = t.n + excluded.n | insert t {k: "a", n: 1} if absent else set {n: n + new.n} | excluded.f is new.f, t.f the row's f (below) |
UPDATE t SET a = 5, n = n + 1 WHERE k = 'a' RETURNING * | set t {a: 5, n: n + 1} where k = "a" returning * | |
UPDATE jobs SET owner = $1 WHERE run_at <= now() ORDER BY run_at LIMIT 1 RETURNING * | set jobs {owner: $1} where run_at <= now() order run_at limit 1 returning * | MySQL's ORDER BY and LIMIT on a write: a queue's claim |
DELETE FROM t WHERE k = 'a' RETURNING * | del t where k = "a" returning * | |
SELECT a, b FROM t WHERE n > 1 ORDER BY a DESC LIMIT 10 OFFSET 20 | get t select a, b where n > 1 order a desc limit 10 offset 20 | |
SELECT k, count(*) AS n FROM t GROUP BY k HAVING count(*) > 1 | get t select k, count(*) as n group k having count(*) > 1 | |
SELECT * FROM t WHERE x IN (1, 2, 3) | get t where x in [1, 2, 3] | through @hash as in [..] is |
SELECT * FROM t WHERE x NOT IN ('a', 'b') | get t where not (x in ["a", "b"]) | a null x is taken in (below) |
SELECT * FROM t WHERE x IN (SELECT y FROM u WHERE z = 1) | get t where x in (get u select y where z = 1) | a subquery, run once |
SELECT * FROM t WHERE x IN (SELECT DISTINCT y FROM u) | get t where x in (get u select y) | DISTINCT changes nothing there |
SELECT * FROM t WHERE x BETWEEN 1 AND 3 AND y = 2 | get t where x >= 1 and x <= 3 and y = 2 | both bounds in; the first AND is BETWEEN's |
SELECT * FROM t WHERE x NOT BETWEEN 1 AND 3 | get t where not (x >= 1 and x <= 3) | |
SELECT * FROM t WHERE name LIKE 'A%' | get t where name like "A%" | % any run of characters, _ one (below) |
SELECT * FROM t WHERE name NOT ILIKE '%!_%' ESCAPE '!' | get t where not (name ilike "%!_%" escape "!") | ILIKE by the case fold |
SELECT * FROM t WHERE x <> 1 | get t where x != 1 | |
SELECT * FROM t WHERE x IS DISTINCT FROM $1 | get t where x != $1 | FenecQL's = and != take a null as a value |
SELECT * FROM t WHERE x IS NOT DISTINCT FROM $1 | get t where x = $1 | |
SELECT * FROM t WHERE done IS NOT TRUE | get t where not (done = true) | |
SELECT count(*), count(age) FROM users | get users select count(*), count(age) | count(age) the rows where age is not null |
INSERT INTO t VALUES {a: 1}, {a: 2} | insert t [{a: 1}, {a: 2}] | documents name their fields; put t {…}, {…} too |
SELECT * FROM t WHERE x IS NOT NULL ORDER BY x DESC NULLS LAST | get t where x is not null order x desc | NULLS where it says what the order does |
SELECT given || ' ' || family AS name FROM t | get t select given + " " + family as name | || is + |
SELECT * FROM t WHERE name = 'O''Brien' | get t where name = "O'Brien" | '' is a quote in single quotes |
UPDATE "t" SET "n" = 1 WHERE id IN (1, 2) RETURNING "n" | set t {n: 1} where id in [1, 2] returning n | a quoted name where only a name can stand (below) |
SELECT count(DISTINCT u), max(at) FROM t WHERE at >= $1 | get t select count(distinct u), max(at) where at >= $1 | |
SELECT a, CASE WHEN n > 1 THEN 'big' ELSE 'small' END AS size FROM t | get t select a, case when n > 1 then "big" else "small" end as size | |
SELECT u.name FROM users AS u WHERE u.age > 30 | get users select name where age > 30 | an alias, and the names it qualifies, written bare |
SELECT DISTINCT city FROM users ORDER BY city | get users select city group city order city | a group of the list's items, each set of values once (below) |
SELECT city FROM users GROUP BY city | get users select city group city | a group with no aggregate answers its keys |
SELECT city, count(*) AS n FROM users GROUP BY 1 ORDER BY 2 DESC | get users select city, count(*) as n group city order n desc | a key by its place in the list |
SELECT city c, count(*) n FROM users GROUP BY city | get users select city as c, count(*) as n group city | a name without AS, where the list goes on or ends at FROM |
SELECT count(*) FILTER (WHERE age IS NULL) AS unknown, sum(total) FILTER (WHERE paid) AS paid FROM t | get t select count(case when age is null then 1 end) as unknown, sum(case when paid then total end) as paid | what case leaves of each row: every aggregate passes a null over |
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100) | get users where id in (get orders select user_id where total > 100) | the one equality with the row around it is the list, the rest the query's |
SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) | get users where not (id in (get orders select user_id)) | a row whose key is null has no match: kept, as SQL keeps it |
SELECT * FROM t WHERE at = (SELECT max(at) FROM t) | get t where at in (get t select max(at)) | a query's one value: an aggregate, or LIMIT 1 |
UPDATE jobs SET owner = $1 WHERE id = (SELECT id FROM jobs ORDER BY run_at LIMIT 1) | set jobs {owner: $1} where id in (get jobs select id order run_at limit 1) | |
CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL, email varchar(200) UNIQUE, age integer, tags text[], meta jsonb) | create collection users (name text required, email text @unique, age int, tags [text], meta json) | the row's id is the database's; types as fenec import maps them (below) |
CREATE TABLE lines (order_id int, n int, sku text, PRIMARY KEY (order_id, n)) | create collection lines (order_id int required, n int required, sku text, @unique(order_id, n)) | a primary key is its rules: unique, and present |
CREATE INDEX users_age ON users (age) | create index on users (age) @sorted | a btree is @sorted; the index's name is not kept |
CREATE UNIQUE INDEX ON users (email) | create index on users (email) @unique | |
CREATE INDEX ON orders USING hash (user_id) | create index on orders (user_id) @hash | |
CREATE INDEX ON posts (owner, created DESC) | create index on posts (owner, created desc) @sorted | over several columns, an index over several fields |
CREATE INDEX ON docs USING hnsw (emb vector_cosine_ops) WITH (m = 16) | create index on docs (emb) @hnsw(cosine, m=16) | pgvector's operator class names the distance |
CREATE INDEX ON docs USING gin (to_tsvector('english', body)) | create index on docs (body) @text | full text, for match |
Joins
A JOIN on a key is a lookup
from each row of the first table to its matches in the next, answered as
SQL answers it: flat rows, a row a pair — and, for a LEFT
JOIN, a row of nulls where nothing matched — the
columns the list names under SQL's names. Where only one table's columns
are answered and each of its rows is answered once — the other table found
by its id, or DISTINCT — it is that table's
get, the other's rows a filter. Each line below is the
FenecQL it parses to; a lookup's line answers flat, which no
FenecQL text asks for.
| SQL | FenecQL | Notes |
|---|---|---|
SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id WHERE o.total > 100 ORDER BY o.placed DESC LIMIT 20 | get orders select id where total > 100 order placed desc limit 20 lookup users on id = user_id required select name | each order's user; required keeps an order only where a user matched |
SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' ORDER BY u.name | get users select name order name lookup orders on user_id select total where status = "paid" | a user with no paid order is a row, total null |
SELECT l.qty, o.status, u.name FROM lines l JOIN orders o ON o.id = l.order_id JOIN users u ON u.id = o.user_id | get lines select qty lookup orders on id = order_id required select status lookup users on id = user_id required select name | a chain: each join hangs off the table before it |
SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.email = $1 | get orders select id where user_id in (get users select id where email = $1) | the users only a filter: each order found once |
SELECT DISTINCT u.name FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'open' | get users select name where id in (get orders select user_id where status = "open") group name | |
SELECT u.id, count(*) AS n FROM users u JOIN orders o ON o.user_id = u.id WHERE u.city = 'Leeds' GROUP BY u.id | get orders select user_id as id, count(*) as n where user_id in (get users select id where city = "Leeds") group user_id | the orders' rows, the users a filter: u.id is each order's user_id |
SELECT sum(o.total) AS t FROM orders o, users u WHERE u.id = o.user_id AND u.city = 'Leeds' | get orders select sum(total) as t where user_id in (get users select id where city = "Leeds") | a comma is a join, its key in the WHERE |
SELECT u.email FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL | get users select email where not (id in (get orders select user_id)) | the rows a LEFT JOIN finds none for |
SELECT id, body FROM docs WHERE kind = 'note' ORDER BY emb <=> $1 LIMIT 10 | get docs select id, body where kind = "note" near emb $1 limit 10 | pgvector's distance is near where the index measures it (below) |
What a join is read as, and what it cannot be:
- The key.
ONpairs a column of the joined table with one of the table joined just before it — several, joined byAND, are a key over several fields. Where the answer is the joined rows beside each row — read as alookup— the joined table is read by its column, which must be itsid, a field with@hashor the first fields of an index over several, as alookup's child key must be; anything else is refused naming the index to declare. Where only one table's columns are answered and the other is a filter,key in (get …), no index is needed, and a small joined table is answered through the first table's index — that list is held to 100 000 values, as anyin (get …)is. A join that finds the joined table by itsidlooks each row's match up by that id past them, at any size, whichever table comes first and with the joined table filtered, where the other key is anid, an int, a float or a timestamp; an aggregate,DISTINCT,EXISTSorLEFT JOIN … IS NULLover a larger joined table is refused, never cut. Its other conditions read one table's columns. A comma between tables is a join whose key is in theWHERE, andUSING (k)pairskof each. - The names. A column is named by its table's name or alias,
o.total; a name with no table is the first table's — the parser reads no schema, and SQL would have refused one two tables hold — and one the first table has no field of is refused, saying to name it with its table. A column no table has is refused, as SQL refuses it, wherever the statement names it — anONterm too — and so is a table no database holds. A column answers under its name,total, or itsAS; two that would answer under one name — every table'sidunderSELECT *— are refused, since a row holds a name once:SELECT o.*, u.name AS who. - The order and the page are the first table's, whose rows the
others are found for:
ORDER BYnames its columns, andLIMITcounts the flat rows, the first table read as far as the page could need. Ordering by a joined table's column would put each row's matches in order rather than the rows: refused, saying to name that table first. - Aggregates and
DISTINCTread one table's columns, the other's rows a filter, where each row of it is counted once: folded over pairs, a row matching three times would be counted three times. Grouping one table's rows with another's column beside each group is alookupafter the groups:get orders select user_id, count(*) as n group user_id lookup users on id = user_id select name. - A
LEFT JOIN's table in theWHEREreads the rows it kept with nulls:o.id IS NULLis the rows with none, and anything else is refused — a condition on its rows goes inON, and one that drops the rows kept with nulls makes it an inner join, writtenJOIN. A condition in aLEFT JOIN'sONon a table before it keeps that table's rows and drops only their matches, which nolookupsays: refused. An inner join after aLEFT JOIN, aRIGHT,FULLorCROSS JOINand a condition between two tables' columns other than the key are refused.
Refused, and what to write
Each is refused with the FenecQL in its error.
| SQL | FenecQL | Why |
|---|---|---|
ALTER TABLE t ADD COLUMN a int | alter collection t add field a int | |
DROP TABLE t | drop collection t | |
TRUNCATE t | del t | |
BEGIN | a /batch | no transaction is held open across requests; a /batch lands whole or not at all, and require makes a count its condition |
CREATE TABLE t (n int DEFAULT 0, u int REFERENCES users(id)) | create collection t (n int, u int @hash) | no default, foreign key or CHECK: dropped, the collection would take writes SQL refuses (below) |
CREATE TABLE t (price numeric(10, 2)) | create collection t (price_cents int) | no decimal: an int of the smallest unit keeps it exact |
CREATE TABLE t (id uuid PRIMARY KEY, n int) | create collection t (uuid text required @unique, n int) | a row's id is an int the database makes |
CREATE INDEX ON docs USING ivfflat (emb vector_l2_ops) | create index on docs (emb) @hnsw(l2) | the vector index is a graph |
SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id ORDER BY u.name | get users order name lookup orders on user_id required select id | a join's rows are put in order by the first table's columns: name the table it orders by first |
SELECT u.name, count(*) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.name | get orders select user_id, count(*) as n group user_id lookup users on id = user_id select name | an aggregate over a join reads one table's columns; the groups, then each one's user |
SELECT * FROM users u RIGHT JOIN orders o ON o.user_id = u.id | select * from orders o left join users u on u.id = o.user_id | a RIGHT JOIN is a LEFT JOIN the other way round; FULL and CROSS are none |
SELECT u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.total IS NULL | select u.name from users u left join orders o on o.user_id = u.id and o.total is null | the WHERE would read the rows kept with nulls; in ON it filters the orders |
SELECT * FROM t WHERE x = (SELECT y FROM u) | get t where x in (get u select y) | the query may answer several rows, where SQL's = refuses a second: IN, or LIMIT 1 |
SELECT (SELECT name FROM products p WHERE p.id = l.product_id) AS name FROM lines l | select p.name from lines l join products p on p.id = l.product_id | another table's row beside each row is a join |
SELECT * FROM t WHERE meta->>'source' = 'web' | get t where meta.source = 'web' | a json field's member is a path; json_extract(meta, '$.source') is answered so too |
SELECT id, emb <-> $1 AS d FROM docs ORDER BY emb <-> $1 LIMIT 5 | get docs select id near emb $1 limit 5 | each row's _score holds what the distance is turned into: the L2 distance, the cosine similarity, the inner product (below) |
SELECT * FROM docs WHERE to_tsvector(body) @@ to_tsquery('rust') | get docs match body "rust" | full text is match over an @text field; MySQL's MATCH … AGAINST too |
SELECT * FROM users WHERE email ~ '@example\.org$' | get users where email like '%@example.org' | ~ is contains, not a regular expression (below) |
SELECT * FROM t ORDER BY x NULLS LAST | get t order x is null, x | a null comes first ascending |
INSERT INTO t VALUES (1, 'x') | insert into t (a, b) values (1, 'x') | the fields are named (below) |
INSERT INTO t (a) VALUES (1), (2, 3) | insert into t (a) values (1), (2) | a row of as many values as fields |
INSERT INTO t (a) SELECT a FROM u | get u select a, then insert t $1 | an insert writes documents, not a query's rows |
INSERT INTO t (k) VALUES ('a') ON CONFLICT ON CONSTRAINT t_k DO NOTHING | insert into t (k) values ('a') on conflict (k) do nothing | the target is the unique field (below) |
INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO UPDATE SET n = 1 WHERE t.n < 3 | insert t {k: "a", n: 1} if absent else set {n: case when n < 3 then 1 else n end} | the upsert sets the row it finds, with no WHERE |
SELECT * FROM t WHERE "name" = 'Ada' | get t where name = 'Ada' | "name" is text here (below) |
Not read, with no error of its own: WITH, window
functions, CAST and ::, LIMIT x,
y, UNION, INSERT OR IGNORE and
REPLACE, MySQL's ON DUPLICATE KEY, ?
placeholders ($1 is a parameter).
Where the meaning is not SQL's
Nulls: two values, not three
SQL's logic has three values: a comparison with a null is unknown, and
WHERE keeps only what is true. FenecQL's has two. A null is
equal to a null and to nothing else, so = and !=
take it as a value — they are SQL's IS NOT DISTINCT FROM and
IS DISTINCT FROM — while <,
<=, >, >=,
LIKE and BETWEEN are false with a null on either
side, and NOT makes a false true. Where SQL's unknown leaves
a row out, FenecQL may take it in:
Where x is null | SQL | FenecQL |
|---|---|---|
x = NULL | never true | true: it is x IS NULL |
x <> 1, NOT (x > 1) | left out | taken in |
x NOT IN (1, 2), x NOT LIKE 'a%', x NOT BETWEEN 1 AND 3 | left out | taken in |
x IN (1, NULL) | left out | taken in: the list holds a null |
2 NOT IN (1, NULL) | never true | true |
x NOT IN (SELECT y …), where some y is null | never true | the subquery's nulls are left out of its list |
Add AND x IS NOT NULL to leave out what SQL leaves out.
Arithmetic and || with a null are null, as in SQL;
aggregates skip nulls, as in SQL — count(x) counts the rows
where x is not null — and sum over none is null.
A null sorts first ascending and last descending, as in SQLite and MySQL —
PostgreSQL's default is the other way round — so NULLS FIRST
is read after ASC and NULLS LAST after
DESC, and either other way is refused. x LIKE
'a%' in a select list is null for a null.
<> is !=, null and all. One could
read <> as SQL's and keep != FenecQL's, but
not exactly: SQL's unknown runs through every NOT above it,
which no rewrite of the one comparison carries, so the two would part
somewhere else instead. Two operators that differ on null alone would be
the subtler trap, and FenecQL read <> as
!= before it read any other SQL, so a text that used it would
change its answer. One meaning, then, written here.
Double quotes are text
In SQL "name" is a column; in FenecQL it is text, as
'name' is. Where only a name can stand — a collection, the
fields of an INSERT, a field SET assigns,
RETURNING, an index's fields, a facet — "name" is
the name, as in SQL. Where a value can stand it is text, as it always was:
where title = "Rust". Where SQL would read a column and text
would make the answer the same for every row, it is refused rather than
answer all the rows or none:
- alone on the left of a test against values:
"name" = 'Ada',"age" > $1,"x" IS NULL,"x" IN (1, 2),"x" LIKE 'a%',"x" BETWEEN 1 AND 2; - on the right of a test against a literal:
'Ada' = "name"; - an item of a select list, a key of
ORDER BYorGROUP BY, an aggregate's argument:SELECT "name",max("at").
A parameter tested against double-quoted text stays a test: a
policy's $jwt.role = "admin". Text on the right of a field
stays text: UPDATE t SET a = "b" sets a to the
text b, where SQL would copy the column b.
~ is contains
In PostgreSQL ~ matches a regular expression; in FenecQL
it finds the text as written, case-insensitively: email ~
"@example.org". Written as a pattern, it would run and match
nothing — and an agent that ran email ~ '@example\.org$'
told its user there were no such rows. So a text that reads as a regular
expression — anchored, ^a or a$, or holding
.* or .+ — is refused, pointing at
LIKE: like 'abc%' for a start, like
'%abc' for an end, like 'a%b' for a gap. A parameter is
taken as the text it is.
LIKE and ILIKE
% stands for any run of characters and _ for
one character — of however many bytes. There is no escape character
unless ESCAPE names one, as in standard SQL and SQLite;
PostgreSQL's default is \, and a FenecQL string reads
\ as an escape of its own, so '50\%' is the text
50%: write LIKE '50!%' ESCAPE '!'. A pattern
that ends in its escape is refused.
A nocase field compares by its case fold wherever it is
compared, LIKE too, as PostgreSQL's citext does.
ILIKE compares the case folds — Unicode's full fold, the one
nocase uses — so
'Straße' ILIKE 'STRASSE', and _ stands for one
character of the fold. Its escape takes no character with a case. A
collated field's LIKE compares its bytes, as its
= does. ~, FenecQL's own, is a
case-insensitive contains.
LIKE reads every row the rest of the filter leaves: no
index answers it. A prefix over a @sorted field walks its
index written as a range, name >= 'abc' and name <
'abd'.
An insert names its fields
INSERT INTO t VALUES (…) without its fields is refused.
The parser reads no schema, and a collection's fields have no order a
statement could lean on: id is the database's and no
field's, and a field added goes last. Each row of VALUES has
as many values as there are fields named. An id among them
names the row's id; one that is taken is refused, as a key is, unless
ON CONFLICT says otherwise.
ON CONFLICT
FenecQL's upsert is held by any unique key — the id, or a
@unique value, a field's or several fields' — and
ON CONFLICT is read with no target, as SQLite reads it:
DO NOTHING passes a row over where any of them is held, and
DO UPDATE SET sets the row holding its id, or
else the first @unique value it holds, in the order the
fields are declared. A target, ON CONFLICT (email), is
taken where it names the one unique key the collection's rows are found by
— its @unique field, the fields of a unique index over several,
or id where there is no other — and refused, naming the keys,
where the collection has another or a row names an id beside
it: the target and the upsert, held by any, would part there. In
DO UPDATE SET,
excluded.f is the row offered (new.f) and
t.f, or f, the row held; a WHERE is
refused.
Text, ||, numbers
||is+. It joins two texts, and a text and a number are refused rather than made text:'n' || 1is an error where PostgreSQL givesn1. MySQL's||, anOR, is not read.- Text compares by its bytes, as
COLLATE "C"does. A field declaredcollate trorundorders by that collation, and its=andLIKEcompare bytes — see Collation. - Division between integers is whole, toward zero:
7 / 2is3, as in PostgreSQL and SQLite (MySQL's is3.5); an integer and a float make a float. - Every row has an
id, an int the database makes and no field declares;/_schema?as=fenecqland the MCPschematool say so in a comment.rowidand_idare refused naming it. - Errors name what to write.
contains(…),starts_with(…)andends_with(…)are answered withLIKE;users.email— a column named with its table — with the field written bare.
DISTINCT, EXISTS and a query's one value
SELECT DISTINCT a, b is a group of its items, which
answers each set of their values once, as does a GROUP BY
with no aggregate; ORDER BY then names the list's columns, as
SQL requires of DISTINCT. Over an aggregating list whose keys
it lists it changes nothing, and DISTINCT ON is refused:
first(name by signup) is the row of each key it asks for.
ORDER BY 2 and GROUP BY 1 name the list's items
by their places — written with BY, as SQL writes them: in
FenecQL's order 1 and group 1 the number is a
key, as it always was. Over *, whose places the parser
reading no schema cannot count, a number is refused, saying to name the
columns; one naming an aggregate as a group's key is refused as SQL
refuses it.
EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND …)
takes the one equality between a column of its query's table and one of
the row around it as the list an in (get …) holds, and the
rest of its WHERE as that query's: u.id in (get orders
select user_id where …). A row whose key is null has no match — so
NOT EXISTS keeps it, as in SQL — and a name with no table is
the query's own, as SQL reads it first. Correlated any other way, it is
refused. = (SELECT …) is the same list where its query answers
one row — an aggregate with no group, or LIMIT 1 — and refused
where it may answer several, which SQL would refuse at the second; where
it answers none, SQL's comparison is unknown and FenecQL's in
false, the same under NOT as NOT
IN.
CREATE TABLE
Each column's type is the one fenec
import gives a column of it — one table for both, PostgreSQL's
names and then SQLite's affinity rules for any other word:
| SQL | FenecQL |
|---|---|
smallint integer bigint serial, SQLite's INT… | int |
real double precision float | float |
text varchar(n) char(n) uuid | text |
citext | text nocase |
boolean | bool |
timestamp timestamptz date datetime | timestamp |
bytea blob | bytes |
json jsonb | json |
text[] integer[] | [text] [int] |
vector(n) halfvec(n) sparsevec(n) | vector<n> vector<n, f16> sparse<n> |
point | geo |
numeric decimal money, interval time inet | refused: no counterpart |
- The row's
idis an int the database makes: a columnidof an integer type that is the primary key —serial,generated … as identity,AUTOINCREMENT— is it, and is no field. Anidof another type, or that is no primary key, is refused. - A primary key on other columns is its two rules,
@uniqueandrequired, over several columns an index over them — beside the row'sid, which every row still has.ON CONFLICT (k)finds a row by it. NOT NULLisrequired: a write must give the field a value, and anullis none.UNIQUEis@unique,COLLATE NOCASEnocase,COLLATE "C"the bytes' order.- A type's own rules are not held, as SQLite holds none:
varchar(80)takes a longer text, andchar(n)is not padded with spaces; auuidis text as written, its case kept, where PostgreSQL writes it lowercase; adateis a timestamp, which keeps a time written with it;smallintandintegertake any 64-bit int, past their 16 and 32 bits;realis a 64-bit float, more precise than its 32 bits. - What fenecdb has no rule of is refused rather than dropped,
which would take writes SQL refuses:
DEFAULT,REFERENCESandFOREIGN KEY,CHECK, a computed column, a sequence on another column. A key a join reads is a field with@hash—CREATE INDEX … USING hash. CREATE INDEXkeeps no name. A btree is@sorted(UNIQUE,@unique), over several columns an index over them;USING hash@hash;USING hnsw@hnswby its operator class —vector_l2_ops, pgvector's default,vector_cosine_ops,vector_ip_ops— withWITH (m, ef_construction), held to pgvector's bounds;USING gin (to_tsvector(…, body))@text. A partial index, an expression's andivfflat,gistorbrinhave no counterpart.
pgvector's distances
ORDER BY emb <-> $1 LIMIT 10 is near emb $1
limit 10, nearest first, where the field's index measures the
operator's distance: <-> L2 over @hnsw(l2),
<=> cosine over @hnsw(cosine),
<#> the negative inner product over @hnsw(dot)
or a sparse field's @inverted. Another index's order would be
another, and is refused naming the operator to write. The vector is a
parameter, a list, or pgvector's text '[1, 2, 3]'. The search
walks the graph, as pgvector's HNSW index scan does: the nearest are found
approximately, and near … exact measures every row.
Each row's distance is in its _score, turned to a
similarity: for <-> it is the L2 distance itself, for
<=> 1 minus the cosine distance — the cosine similarity
— and for <#> the inner product, minus pgvector's
value. The operator in the list or the WHERE, DESC
and a second key are refused: near ranks the rows, and the
distance is the score.