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.

SQLFenecQLNotes
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, ainsert t {a: $1} returning id, athe rows written, the ids made among them
INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO NOTHINGinsert t {k: "a", n: 1} if absentpassed 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.ninsert 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 20get 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(*) > 1get 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 = 2get t where x >= 1 and x <= 3 and y = 2both bounds in; the first AND is BETWEEN's
SELECT * FROM t WHERE x NOT BETWEEN 1 AND 3get 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 <> 1get t where x != 1
SELECT * FROM t WHERE x IS DISTINCT FROM $1get t where x != $1FenecQL's = and != take a null as a value
SELECT * FROM t WHERE x IS NOT DISTINCT FROM $1get t where x = $1
SELECT * FROM t WHERE done IS NOT TRUEget t where not (done = true)
SELECT count(*), count(age) FROM usersget 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 LASTget t where x is not null order x descNULLS where it says what the order does
SELECT given || ' ' || family AS name FROM tget 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 na quoted name where only a name can stand (below)
SELECT count(DISTINCT u), max(at) FROM t WHERE at >= $1get t select count(distinct u), max(at) where at >= $1
SELECT a, CASE WHEN n > 1 THEN 'big' ELSE 'small' END AS size FROM tget t select a, case when n > 1 then "big" else "small" end as size
SELECT u.name FROM users AS u WHERE u.age > 30get users select name where age > 30an alias, and the names it qualifies, written bare
SELECT DISTINCT city FROM users ORDER BY cityget users select city group city order citya group of the list's items, each set of values once (below)
SELECT city FROM users GROUP BY cityget users select city group citya group with no aggregate answers its keys
SELECT city, count(*) AS n FROM users GROUP BY 1 ORDER BY 2 DESCget users select city, count(*) as n group city order n desca key by its place in the list
SELECT city c, count(*) n FROM users GROUP BY cityget users select city as c, count(*) as n group citya 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 tget t select count(case when age is null then 1 end) as unknown, sum(case when paid then total end) as paidwhat 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) @sorteda 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) @sortedover 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) @textfull 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.

SQLFenecQLNotes
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 20get orders select id where total > 100 order placed desc limit 20 lookup users on id = user_id required select nameeach 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.nameget 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_idget lines select qty lookup orders on id = order_id required select status lookup users on id = user_id required select namea 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 = $1get 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.idget orders select user_id as id, count(*) as n where user_id in (get users select id where city = "Leeds") group user_idthe 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 NULLget 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 10get docs select id, body where kind = "note" near emb $1 limit 10pgvector's distance is near where the index measures it (below)

What a join is read as, and what it cannot be:

  • The key. ON pairs a column of the joined table with one of the table joined just before it — several, joined by AND, are a key over several fields. Where the answer is the joined rows beside each row — read as a lookup — the joined table is read by its column, which must be its id, a field with @hash or the first fields of an index over several, as a lookup'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 any in (get …) is. A join that finds the joined table by its id looks 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 an id, an int, a float or a timestamp; an aggregate, DISTINCT, EXISTS or LEFT JOIN … IS NULL over 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 the WHERE, and USING (k) pairs k of 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 — an ON term too — and so is a table no database holds. A column answers under its name, total, or its AS; two that would answer under one name — every table's id under SELECT * — 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 BY names its columns, and LIMIT counts 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 DISTINCT read 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 a lookup after 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 the WHERE reads the rows it kept with nulls: o.id IS NULL is the rows with none, and anything else is refused — a condition on its rows goes in ON, and one that drops the rows kept with nulls makes it an inner join, written JOIN. A condition in a LEFT JOIN's ON on a table before it keeps that table's rows and drops only their matches, which no lookup says: refused. An inner join after a LEFT JOIN, a RIGHT, FULL or CROSS JOIN and 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.

SQLFenecQLWhy
ALTER TABLE t ADD COLUMN a intalter collection t add field a int
DROP TABLE tdrop collection t
TRUNCATE tdel t
BEGINa /batchno 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.nameget users order name lookup orders on user_id required select ida 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.nameget orders select user_id, count(*) as n group user_id lookup users on id = user_id select namean 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.idselect * from orders o left join users u on u.id = o.user_ida 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 NULLselect u.name from users u left join orders o on o.user_id = u.id and o.total is nullthe 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 lselect p.name from lines l join products p on p.id = l.product_idanother 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 5get docs select id near emb $1 limit 5each 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 LASTget t order x is null, xa 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 uget u select a, then insert t $1an insert writes documents, not a query's rows
INSERT INTO t (k) VALUES ('a') ON CONFLICT ON CONSTRAINT t_k DO NOTHINGinsert into t (k) values ('a') on conflict (k) do nothingthe target is the unique field (below)
INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO UPDATE SET n = 1 WHERE t.n < 3insert 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 nullSQLFenecQL
x = NULLnever truetrue: it is x IS NULL
x <> 1, NOT (x > 1)left outtaken in
x NOT IN (1, 2), x NOT LIKE 'a%', x NOT BETWEEN 1 AND 3left outtaken in
x IN (1, NULL)left outtaken in: the list holds a null
2 NOT IN (1, NULL)never truetrue
x NOT IN (SELECT y …), where some y is nullnever truethe 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 BY or GROUP 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' || 1 is an error where PostgreSQL gives n1. MySQL's ||, an OR, is not read.
  • Text compares by its bytes, as COLLATE "C" does. A field declared collate tr or und orders by that collation, and its = and LIKE compare bytes — see Collation.
  • Division between integers is whole, toward zero: 7 / 2 is 3, as in PostgreSQL and SQLite (MySQL's is 3.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=fenecql and the MCP schema tool say so in a comment. rowid and _id are refused naming it.
  • Errors name what to write. contains(…), starts_with(…) and ends_with(…) are answered with LIKE; 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:

SQLFenecQL
smallint integer bigint serial, SQLite's INT…int
real double precision floatfloat
text varchar(n) char(n) uuidtext
citexttext nocase
booleanbool
timestamp timestamptz date datetimetimestamp
bytea blobbytes
json jsonbjson
text[] integer[][text] [int]
vector(n) halfvec(n) sparsevec(n)vector<n> vector<n, f16> sparse<n>
pointgeo
numeric decimal money, interval time inetrefused: no counterpart
  • The row's id is an int the database makes: a column id of an integer type that is the primary key — serial, generated … as identity, AUTOINCREMENT — is it, and is no field. An id of another type, or that is no primary key, is refused.
  • A primary key on other columns is its two rules, @unique and required, over several columns an index over them — beside the row's id, which every row still has. ON CONFLICT (k) finds a row by it.
  • NOT NULL is required: a write must give the field a value, and a null is none. UNIQUE is @unique, COLLATE NOCASE nocase, COLLATE "C" the bytes' order.
  • A type's own rules are not held, as SQLite holds none: varchar(80) takes a longer text, and char(n) is not padded with spaces; a uuid is text as written, its case kept, where PostgreSQL writes it lowercase; a date is a timestamp, which keeps a time written with it; smallint and integer take any 64-bit int, past their 16 and 32 bits; real is 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, REFERENCES and FOREIGN 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 INDEX keeps no name. A btree is @sorted (UNIQUE, @unique), over several columns an index over them; USING hash @hash; USING hnsw @hnsw by its operator class — vector_l2_ops, pgvector's default, vector_cosine_ops, vector_ip_ops — with WITH (m, ef_construction), held to pgvector's bounds; USING gin (to_tsvector(…, body)) @text. A partial index, an expression's and ivfflat, gist or brin have 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.