Collections and types
What a collection declares and what a field holds: json and paths, points, time, collation and case.
A collection declares its fields and their types, and the type decides
what a field holds, how it compares and which indexes it takes:
bool, int, float, text,
bytes, timestamp, lists such as [text],
json, geo and the vectors.
Run in the shell, answers and all; the timings are from one run.
fenec> create collection people (name text collate tr @sorted, email text nocase @unique, meta json);
collection `people` created
fenec> put people [{name: "Zeynep", email: "Zeynep@Example.com", meta: {plan: "pro", seats: 3}}, {name: "Çağla", email: "cagla@example.com", meta: {plan: "free", seats: 1}}, {name: "İlker", email: "ilker@example.com", meta: {plan: "free", seats: 1}}, {name: "Irmak", email: "irmak@example.com", meta: {plan: "free", seats: 1}}, {name: "Cem", email: "cem@example.com", meta: {plan: "pro", seats: 5}}];
5 records (1.43ms)
fenec> describe people
people
name text collate tr @sorted
email text nocase @unique
meta json
fenec> get people select name order name
name
------
Cem
Çağla
Irmak
İlker
Zeynep
(5 rows, 112.33µs)
fenec> get people select email where email = "ZEYNEP@example.com"
email
------------------
Zeynep@Example.com
(1 rows, 148.46µs)
fenec> get people select name, meta.plan where meta.seats >= 3 order meta.seats desc
name meta.plan
------ ---------
Cem pro
Zeynep pro
(2 rows, 138.71µs)
fenec> alter collection people add field city text;
field `city` added to `people`
fenec> get people select name, city where name = "Irmak"
name city
----- ----
Irmak -
(1 rows, 125.58µs)describe shows the schema the file holds, and the shell
prints a null as -. The names come out in Turkish order because
the field says collate tr (Collation); the
email compares by its case fold because it is nocase
(Case-insensitive text); meta.plan is a path
into a json field (Objects and paths); and the
field added last reads null in every row written before it, with no
document rewritten (Changing a
collection). The grammar of create collection is under
Statements.
Objects and paths
A json field holds any value JSON does — an object, a list,
a number, text, true, false or
null — with no type declared for what is inside. A path
reads into it wherever a field is read.
create collection docs (title text, meta json)put docs {title: "a", meta: {lang: "tr", source: {site: "x", rank: 3}}}
get docs where meta.lang = "tr" and meta.source.rank >= 2
get docs select title, meta.source.site order meta.source.rank desc
set docs {meta.lang: "en"} where title = "a"await db.from('docs')
.insert({ title: 'a', meta: { lang: 'tr', source: { site: 'x', rank: 3 } } });
await db.from('docs')
.where('meta.lang', 'tr')
.where('meta.source.rank', '>=', 2)
.rows();
await db.from('docs')
.select('title', 'meta.source.site')
.order('meta.source.rank', 'desc')
.rows();
await db.from('docs').where('title', 'a').update({ 'meta.lang': 'en' });(db.collection("docs")
.insert({"title": "a", "meta": {"lang": "tr", "source": {"site": "x", "rank": 3}}}))
(db.collection("docs")
.where("meta.lang", "tr")
.where("meta.source.rank", ">=", 2)
.rows())
(db.collection("docs")
.select("title", "meta.source.site")
.order("meta.source.rank", "desc")
.rows())
db.collection("docs").where("title", "a").update({"meta.lang": "en"})db.From("docs").
Insert(
ctx,
fenecdb.D("title", "a", "meta", fenecdb.D("lang", "tr", "source", fenecdb.D("site", "x", "rank", 3))),
)
db.From("docs").
Where("meta.lang", "=", "tr").
Where("meta.source.rank", ">=", 2).
Rows(ctx)
db.From("docs").
Select("title", "meta.source.site").
Order("meta.source.rank", "desc").
Rows(ctx)
db.From("docs").
Where("title", "=", "a").
Update(ctx, fenecdb.D("meta.lang", "en"))await db.From("docs")
.InsertAsync(new { title = "a", meta = new { lang = "tr", source = new { site = "x", rank = 3 } } });
await db.From("docs")
.Where("meta.lang", "tr")
.Where("meta.source.rank", ">=", 2)
.RowsAsync();
await db.From("docs")
.Select("title", "meta.source.site")
.Order("meta.source.rank", "desc")
.RowsAsync();
await db.From("docs")
.Where("title", "a")
.UpdateAsync(new Dictionary<string, object?> { ["meta.lang"] = "en" });try await db.from("docs")
.insert(["title": "a", "meta": ["lang": "tr", "source": ["site": "x", "rank": 3]]] as Value)
try await db.from("docs")
.where("meta.lang", "tr")
.where("meta.source.rank", ">=", 2)
.rows()
try await db.from("docs")
.select("title", "meta.source.site")
.order("meta.source.rank", "desc")
.rows()
try await db.from("docs")
.where("title", "a")
.update(["meta.lang": "en"] as Value)db.from("docs")
.insert(mapOf("title" to "a", "meta" to mapOf("lang" to "tr", "source" to mapOf("site" to "x", "rank" to 3))))
db.from("docs")
.where("meta.lang", "tr")
.where("meta.source.rank", ">=", 2)
.rows()
db.from("docs")
.select("title", "meta.source.site")
.order("meta.source.rank", "desc")
.rows()
db.from("docs").where("title", "a").update(mapOf("meta.lang" to "en"))await db.from('docs')
.insert({'title': 'a', 'meta': {'lang': 'tr', 'source': {'site': 'x', 'rank': 3}}});
await db.from('docs')
.where('meta.lang', 'tr')
.where('meta.source.rank', '>=', 2)
.rows();
await db.from('docs')
.select(['title', 'meta.source.site'])
.order('meta.source.rank', 'desc')
.rows();
await db.from('docs').where('title', 'a').update({'meta.lang': 'en'});put docs {title: $1, meta: $2}
-- ["a", {"lang": "tr", "source": {"site": "x", "rank": 3}}]
get docs where meta.lang = $1 and meta.source.rank >= $2
-- ["tr", 2]
get docs select title, meta.source.site order meta.source.rank desc
set docs {meta.lang: $1} where title = $2
-- ["en", "a"]create index on docs (meta.lang) @hash
create index on docs (meta.source.rank) @sorted- A path that leads nowhere is
null: a key a document does not have, or a value on the way that is not an object. - A path takes
@hash,@uniqueand@sorted, and they answer what the scan would, row for row. setwith a path changes one key and keeps the rest of the object; it makes the object where there is none.- Typed fields are unchanged. An
intor atextfield still refuses an object.
How objects keep their keys and numbers, and what paths cost
An object's keys are kept sorted and unique: a key given twice — in
FenecQL, in a JSON body, in a parameter — refuses the write rather than
keep one of them, since which one a reader keeps differs from reader to
reader. Its numbers are kept as written, an integer an integer and
19.99 the decimal it is, everywhere one comes in: FenecQL, a
parameter over HTTP and in the browser, a JSON body, a row
fenec import reads. A list of numbers alone is read the quick way, into the
f32s of the vector it nearly always is; where it is given a
json field, or compared with a path into one, the statement's
text or the parameter's JSON is read again as written, so
[0.1, 12345678901] is kept as those two numbers. A vector that
was never text — a Float32Array handed to the browser module,
a vector built through the Rust API — is refused there rather than kept as
numbers it was not given. A timestamp becomes its ISO text, as JSON writes
one; bytes, a sparse vector and a number that is not finite have no JSON
and are refused. A value nests at most 64 levels, and a path
names at most 64 keys past its field: past either a write or a query is
refused, never cut.
A path is a field's name, a dot, and keys, each a name as a field's is
(meta.source.rank); a key with a space or a dash in it cannot
be named, and is reached by reading the whole field. It works in every
comparison, in, has, ~ and
is null, in select — answering under the path's
text as its column's name — in order, and in
set and put, which set the one key inside the
field. A filter binds a path once a query, as it binds a field, and walks
its keys over each row's bytes, passing the members before the key without
decoding them. An aggregate and group read a field, not a
path; a lookup's keys are fields too. Comparisons are the
ones every value already has: 3 equals 3.0,
every number orders below every text, text below a list and a list below
an object, and null is equal only to null.
An index on a path is a field of its own beside the fields, named by the
path, and its kind is the field's: @hash keys a value as a
typed field's does, a whole float filed as the int it equals so that
3 and 3.0 share a bucket; an equality or an
in reaches it with null, a boolean, text and a number an
f64 holds exactly, and anything else — an int past
253, which equals floats near it that no bucket holds — is
answered by the scan. @unique refuses a value held twice as a
field's does, 3 and 3.0 the same value.
@sorted orders the numbers as f64s and the text by
its bytes, every number before every text; a value it has no place for —
true or false, a list, an object, an int past
253 — is held apart, and while the index holds one it answers
nothing and the scan does: a wrong order is never what an index costs.
tests/json.rs holds both to a twin collection without them,
every filter, order and page, through writes and a reopen. Text, vector
and sparse indexes stay on the fields that declare their type. On a server
an index on a path is built under the write lock, as it reads one value a
row: 11 ms for @hash and 21 for @sorted over
100 000 documents.
Renaming a json field renames the paths into it, indexes
and all, and dropping it drops them. In the file a json field
is type tag 14 and an object value tag 13 — not 11 or 12, which a schema
writes for a collation and a dropped place — and each index on a path is
written inside its field's entry, its keys and its kind: a binary from
before them refuses the file (unknown type tag 14) rather than read
it. The indexes are derived, as every hash and ordered index is: rebuilt
from the documents by the first read after an open.
Measured over 100 000 documents of a title, a
year, a price and a meta object of
three keys: where meta.lang = "tr" scans in 5.7 ms where the
same comparison of a text field takes 4.2; meta.source.rank >=
990 in 7.2; both together in 10.4; the first 20 by
meta.source.rank in 7.3. With @hash and
@sorted on the two paths: 0.025, 0.009, 0.12 and 0.010 ms. A
scan of a collection with no json field takes what it took
before them (3.47 against 3.38 ms by two numbers, 4.17 against 4.21 by a
text, 3.06 against 3.08 by ~, 2.72 against 2.60 for an
ordered page), and the browser module's filter 1.67 against 1.66.
Points
A geo field holds a point on the earth, [lon,
lat] in degrees. distance measures in metres,
within tests a box, and near over the field puts
rows nearest first. The recipes, and what they cost against PostGIS and
Redis, are in Points and distances.
create collection places (name text, loc geo @geo)
put places {name: "Catania", loc: [15.087269, 37.502669]}
get places select name, distance(loc, $1) as m where distance(loc, $1) <= 2000
get places where within(loc, [12, 37, 16, 39]) count
get places select name near loc $1 limit 10distance(a, b)is the haversine on a sphere of Redis's radius, 6 372 797.56 m: Redis'sGEODIST. PostGIS's sphere, the earth's mean radius, gives 0.03% less. Either side is a point field or a point; a null on either side is null.within(p, [west, south, east, north])holds a point on the box's edge; a west east of its east crosses the antimeridian.near loc porders by distance, a tie by id, with the metres as_score;exactmeasures every row, andef, a graph's, is refused.@geonarrowsdistance(loc, p) <= r(or<, either way round) andwithin(loc, box)in anandchain, and walksnearnearest first. It is derived, as@sortedis: built by the first read after an open, nothing in the file but the field's declaration.- A point is refused past 180 degrees of longitude or 90 of latitude, and a list of other than two numbers.
Time
A timestamp field takes an ISO-8601 date or epoch
milliseconds, and always comes back as ISO-8601 in UTC.
put events {name: "login", t: "2026-09-19T12:34:56Z"}
put events {name: "logout", t: 1758285296000} -- epoch ms
get events where t >= "2026-01-01" and t < now()await db.from('events')
.insert({ name: 'login', t: '2026-09-19T12:34:56Z' });
await db.from('events').insert({ name: 'logout', t: 1758285296000 });
await db.from('events')
.where('t', '>=', '2026-01-01')
.where(raw('t < now()'))
.rows();(db.collection("events")
.insert({"name": "login", "t": "2026-09-19T12:34:56Z"}))
db.collection("events").insert({"name": "logout", "t": 1758285296000})
(db.collection("events")
.where("t", ">=", "2026-01-01")
.where(raw("t < now()"))
.rows())db.From("events").
Insert(ctx, fenecdb.D("name", "login", "t", "2026-09-19T12:34:56Z"))
db.From("events").
Insert(ctx, fenecdb.D("name", "logout", "t", 1758285296000))
db.From("events").
Where("t", ">=", "2026-01-01").
WhereCond(fenecdb.Raw("t < now()")).
Rows(ctx)await db.From("events")
.InsertAsync(new { name = "login", t = "2026-09-19T12:34:56Z" });
await db.From("events")
.InsertAsync(new { name = "logout", t = 1758285296000 });
await db.From("events")
.Where("t", ">=", "2026-01-01")
.Where(Cond.Raw("t < now()"))
.RowsAsync();try await db.from("events")
.insert(["name": "login", "t": "2026-09-19T12:34:56Z"] as Value)
try await db.from("events")
.insert(["name": "logout", "t": 1758285296000] as Value)
try await db.from("events")
.where("t", ">=", "2026-01-01")
.where(.raw("t < now()"))
.rows()db.from("events")
.insert(mapOf("name" to "login", "t" to "2026-09-19T12:34:56Z"))
db.from("events")
.insert(mapOf("name" to "logout", "t" to 1758285296000))
db.from("events")
.where("t", ">=", "2026-01-01")
.where(Cond.raw("t < now()"))
.rows()await db.from('events')
.insert({'name': 'login', 't': '2026-09-19T12:34:56Z'});
await db.from('events').insert({'name': 'logout', 't': 1758285296000});
await db.from('events')
.where('t', '>=', '2026-01-01')
.where(Cond.raw('t < now()'))
.rows();put events {name: $1, t: $2}
-- ["login", "2026-09-19T12:34:56Z"]
put events {name: $1, t: $2}
-- ["logout", 1758285296000]
get events where t >= $1 and t < now()
-- ["2026-01-01"]Why timestamp is a type of its own
timestamp holds UTC epoch milliseconds as an
i64. Writes accept both text and numbers; a text literal in a
comparison is parsed.
The representation is always ISO-8601
(2026-09-19T12:34:56.789Z), in JSON and in FenecQL alike. The calendar conversion is
integer arithmetic including leap-year and century rules — no table, no
dependency.
It exists as a separate type rather than an alias over int
because ResultSet does not carry the schema, so an alias would
show every client a raw number.
Collation
Text sorts by its bytes unless you ask otherwise, which is wrong in every
language. collate und sorts it the way people expect in any
language, and collate tr in Turkish order.
get people order name collate und asc limit 20
get people order city collate tr desc, name collate tr asc
get people select city, count(*) group city order city collate und ascawait db.from('people')
.order('name', 'asc', { collate: 'und' })
.limit(20)
.rows();
await db.from('people')
.order('city', 'desc', { collate: 'tr' })
.order('name', 'asc', { collate: 'tr' })
.rows();
await db.from('people')
.select('city', 'count(*)')
.group('city')
.order('city', 'asc', { collate: 'und' })
.rows();(db.collection("people")
.order("name", "asc", collate="und")
.limit(20)
.rows())
(db.collection("people")
.order("city", "desc", collate="tr")
.order("name", "asc", collate="tr")
.rows())
(db.collection("people")
.select("city", "count(*)")
.group("city")
.order("city", "asc", collate="und")
.rows())db.From("people").
Order("name", "asc", fenecdb.Collate("und")).
Limit(20).
Rows(ctx)
db.From("people").
Order("city", "desc", fenecdb.Collate("tr")).
Order("name", "asc", fenecdb.Collate("tr")).
Rows(ctx)
db.From("people").
Select("city", "count(*)").
Group("city").
Order("city", "asc", fenecdb.Collate("und")).
Rows(ctx)await db.From("people")
.Order("name", "asc", collate: "und")
.Limit(20)
.RowsAsync();
await db.From("people")
.Order("city", "desc", collate: "tr")
.Order("name", "asc", collate: "tr")
.RowsAsync();
await db.From("people")
.Select("city", "count(*)")
.Group("city")
.Order("city", "asc", collate: "und")
.RowsAsync();try await db.from("people")
.order("name", "asc", collate: "und")
.limit(20)
.rows()
try await db.from("people")
.order("city", "desc", collate: "tr")
.order("name", "asc", collate: "tr")
.rows()
try await db.from("people")
.select("city", "count(*)")
.group("city")
.order("city", "asc", collate: "und")
.rows()db.from("people").order("name", "asc", collate = "und").limit(20).rows()
db.from("people")
.order("city", "desc", collate = "tr")
.order("name", "asc", collate = "tr")
.rows()
db.from("people")
.select("city", "count(*)")
.group("city")
.order("city", "asc", collate = "und")
.rows()await db.from('people').order('name', 'asc', 'und').limit(20).rows();
await db.from('people')
.order('city', 'desc', 'tr')
.order('name', 'asc', 'tr')
.rows();
await db.from('people')
.select(['city', 'count(*)'])
.group('city')
.order('city', 'asc', 'und')
.rows();get people order name collate und asc limit $1
-- [20]
get people order city collate tr desc, name collate tr asc
get people select city, count(*) group city order city collate und asccreate collection people (name text collate und @sorted, city text)get people where name > $1 order name asc limit 20 -- the page after $1await db.from('people')
.where('name', '>', 'Ada')
.order('name')
.limit(20)
.rows();(db.collection("people")
.where("name", ">", "Ada")
.order("name")
.limit(20)
.rows())db.From("people").
Where("name", ">", "Ada").
Order("name", "asc").
Limit(20).
Rows(ctx)await db.From("people")
.Where("name", ">", "Ada")
.Order("name")
.Limit(20)
.RowsAsync();try await db.from("people")
.where("name", ">", "Ada")
.order("name")
.limit(20)
.rows()db.from("people")
.where("name", ">", "Ada")
.order("name")
.limit(20)
.rows()await db.from('people')
.where('name', '>', 'Ada')
.order('name')
.limit(20)
.rows();get people where name > $1 order name asc limit $2
-- ["Ada", 20]order name | order name collate und | order name collate tr |
|---|---|---|
| Irmak, Zeynep, cem, Çağla, İlker, ılgaz | Çağla, cem, İlker, Irmak, ılgaz, Zeynep | cem, Çağla, ılgaz, Irmak, İlker, Zeynep |
| Zoë, anna, Ζωή, 山田, Борис, Émile | anna, Émile, Zoë, Ζωή, Борис, 山田 | anna, Émile, Zoë, Ζωή, Борис, 山田 |
- Name it on one key in
order, or declare it on a field so every comparison of that field uses it. - Equality still compares bytes, unless the field is
nocase(Case-insensitive text). - In the browser, data for scripts other than Latin is fetched when first needed.
How collate und and tr order text, and what they cost and add to the module
Text is ordered by its bytes unless a key says otherwise, and the bytes are
wrong in every language: every capitalised word comes before every lowercase
one, an accented letter lands after z, and every script past
ASCII falls where its code points do. collate und orders the key
as Unicode's root collation does — CLDR's, which PostgreSQL calls
und-x-icu — the order every language's starts from: each script
in a run of its own, its letters in their order, accents and case deciding
only between words whose letters are the same. A language whose letters sort
otherwise is a tailoring over it, and collate tr is Turkish,
PostgreSQL's tr-x-icu: ç after c,
ğ after g, ı before i
with I as its capital and İ as the capital of
i, ö, ş and ü after
o, s and u.
It compares a level at a time over the whole string, as ICU does: the
letters, then their accents (kar < kâr <
kara), then their case (ince < İnce),
so an accent or a capital only decides between strings whose letters are the
same. What all three leave equal goes by its bytes, as under a deterministic
PostgreSQL collation — the order is total, and a page never changes with the
plan. It applies wherever order does: the query's rows, a
lookup's children, an aggregate's groups by their key or a
min or max of text. A key that holds no text —
a number, id, count(*) — refuses it rather than
ignore it.
A field can be declared in a collation, name text collate
und, and its text is in it wherever it compares: order
without naming one, <, <=, >
and >= in where, min and
max, and a @sorted index over it, which holds that
order and is walked for it. So a listing pages by the last name it
showed — where name > $last order name limit 20 — and the
index answers as the scan does, row for row. Equality stays the bytes', since
the collation ties no two different strings. The collation is the field's:
it is written with the schema into the file, and describe
shows it. A
@sorted field without one keeps its bytes' order, so a key
collated in the query is sorted rather than walked, and a comparison in
where compares bytes.
Over a million Turkish names (160 797 of them distinct) a comparison costs
33.5 ns in tr and 35.2 in und where the bytes cost
6.7, because it starts where two strings part — Ahmet Yılmaz
against Ahmet Kaya is Y against K —
which took it from 68 ns, and a Latin letter's weights are made once per
collation and read, where looking each up through the tailoring and the
root's tables took it to 41:
| 1 000 000 names | bytes | collate tr | collate und | PostgreSQL, "C" | PostgreSQL, "tr-x-icu" |
|---|---|---|---|---|---|
a page, limit 20 | 94.7 ms | 124 ms | 130 ms | 116 ms | 136 ms |
| all of them in order | 374 ms | 890 ms | 925 ms | 506 ms | 1 208 ms |
fenecdb in process, PostgreSQL 17 by its own execution time with the sort
in memory; both on one core (make collate-bench).
The weights are ICU's own, for every assigned code point.
tools/collate/gen.py reads them out of ICU 76.1 into
crates/fenec-core/src/collate/: the root's cut into chunks by
script, and each tailoring as what it changes. Han ideographs sort in the
radical-and-stroke order ICU's root gives them, those of the other planes
among them; a Hangul syllable sorts as its jamo; and two or three
characters ICU sorts as one sort as one — a letter and a mark it composes
with (c and U+0327 is ç), a vowel sign written in
two parts, a Thai or Lao vowel written before the consonant it follows in
speech. An unassigned or private code point goes where ICU puts one. Checked
against ICU itself over 1 250 000 random pairs in every script, of which
five sort apart, and against Intl.Collator("und") over two thousand
words in the browser module on every test run. What it does not do is ICU's
normalisation: a mark that composes with its letter across another mark
between them, or with marks out of their canonical order, sorts as a mark
of its own — those five.
ICU numbers every ideograph and most alphabets' letters one by one — 133 535 primary weights, which no packed entry has the bits for — so consecutive code points that alone give consecutive weights share a rank and are told apart by their code points, and a run of them is held as one range; a table's words are written as differences in LEB128 besides: 154 KB for every script, 324 KB plain, which a native build carries whole — 161 KB of the command line's 1040.
The browser module carries the Latin chunk and fetches the others as a
page first needs them, from collate/ beside the module (JavaScript). A statement that would
compare text of a script whose data it has not got is refused, the chunk
named, rather than answered in another order; a write is refused before it
changes anything, and an image whose collated text needs a chunk loads once
it is there — a @sorted index built without it would be out of
order.
| chunk | bytes | gzip | brotli | holds |
|---|---|---|---|---|
latin | 9.0 KB | 4.9 KB | 4.2 KB | ASCII, Latin-1, Extended-A to -C, Vietnamese, punctuation, currency; in the module |
greek, cyrillic | 3.6, 2.1 KB | 1.6, 1.0 KB | 1.4, 0.9 KB | Greek with polytonic, Cyrillic |
middle-east | 7.3 KB | 3.6 KB | 3.3 KB | Armenian, Hebrew, Arabic, Syriac, Thaana, N'Ko |
indic | 3.0 KB | 1.6 KB | 1.5 KB | Devanagari to Sinhala |
southeast-asia | 8.5 KB | 2.2 KB | 2.2 KB | Thai, Lao, Tibetan, Myanmar, Khmer, Javanese … |
east-asia | 8.2 KB | 4.6 KB | 4.2 KB | Hangul, kana, Bopomofo, Yi, full-width forms |
han, han-ext | 31.1, 59.0 KB | 14.3, 27.3 KB | 12.7, 24.9 KB | ideographs of this plane, of planes 2 and 3 |
symbols | 6.9 KB | 2.8 KB | 2.6 KB | arrows, mathematics, emoji, mathematical letters |
other, smp | 5.7, 9.8 KB | 3.0, 5.0 KB | 2.8, 4.8 KB | every other script, of this plane and the next |
The root and its chunks made the browser module 17.5 KB larger, 8.2 KB under gzip and 6.8 KB under brotli: the Latin chunk, the Turkish tailoring over it, and the code that reads them and has the others fetched. Every other chunk together is 145 KB, 67 KB gzip, 61 KB brotli. Their words are written as differences, which gzip and brotli take a third off: plain they were 102 and 90 KB. The Latin chunk alone comes out 0.3 KB larger under gzip that way, 0.9 KB under brotli, and 2.9 KB smaller in the module.
Case-insensitive text: nocase
A text field declared nocase compares by its case fold
wherever it is compared, and keeps the text as it was written: an email
under @unique takes Alice@x.com once, and finds it
as alice@X.COM. It is PostgreSQL's citext, with
Unicode's case folding where citext has lower().
create collection users (email text nocase @unique, name text collate tr nocase @sorted)put users {email: "Alice@Example.com", name: "İsmail"}
get users where email = "alice@example.COM" -- the row, its email as written
put users {email: "ALICE@example.com"} -- refused: users.email is unique
put users {email: "alice@example.com"} if absent -- passed over, and not counted
get users where name = "ismail" -- the row: İ is the capital of i in Turkish
get users select name order name asc limit 20 -- in Turkish order, case asideawait db.from('users')
.insert({ email: 'Alice@Example.com', name: 'İsmail' });
await db.from('users').where('email', 'alice@example.COM').rows();
await db.from('users').insert({ email: 'ALICE@example.com' });
await db.from('users')
.insert({ email: 'alice@example.com' }, { ifAbsent: true });
await db.from('users').where('name', 'ismail').rows();
await db.from('users').select('name').order('name').limit(20).rows();(db.collection("users")
.insert({"email": "Alice@Example.com", "name": "İsmail"}))
db.collection("users").where("email", "alice@example.COM").rows()
db.collection("users").insert({"email": "ALICE@example.com"})
(db.collection("users")
.insert({"email": "alice@example.com"}, if_absent=True))
db.collection("users").where("name", "ismail").rows()
db.collection("users").select("name").order("name").limit(20).rows()db.From("users").
Insert(ctx, fenecdb.D("email", "Alice@Example.com", "name", "İsmail"))
db.From("users").Where("email", "=", "alice@example.COM").Rows(ctx)
db.From("users").Insert(ctx, fenecdb.D("email", "ALICE@example.com"))
db.From("users").
Insert(ctx, fenecdb.D("email", "alice@example.com"), fenecdb.IfAbsent())
db.From("users").Where("name", "=", "ismail").Rows(ctx)
db.From("users").Select("name").Order("name", "asc").Limit(20).Rows(ctx)await db.From("users")
.InsertAsync(new { email = "Alice@Example.com", name = "İsmail" });
await db.From("users").Where("email", "alice@example.COM").RowsAsync();
await db.From("users").InsertAsync(new { email = "ALICE@example.com" });
await db.From("users")
.InsertAsync(new { email = "alice@example.com" }, ifAbsent: true);
await db.From("users").Where("name", "ismail").RowsAsync();
await db.From("users")
.Select("name")
.Order("name")
.Limit(20)
.RowsAsync();try await db.from("users")
.insert(["email": "Alice@Example.com", "name": "İsmail"] as Value)
try await db.from("users").where("email", "alice@example.COM").rows()
try await db.from("users")
.insert(["email": "ALICE@example.com"] as Value)
try await db.from("users")
.insert(["email": "alice@example.com"] as Value, ifAbsent: true)
try await db.from("users").where("name", "ismail").rows()
try await db.from("users").select("name").order("name").limit(20).rows()db.from("users")
.insert(mapOf("email" to "Alice@Example.com", "name" to "İsmail"))
db.from("users").where("email", "alice@example.COM").rows()
db.from("users").insert(mapOf("email" to "ALICE@example.com"))
db.from("users")
.insert(mapOf("email" to "alice@example.com"), ifAbsent = true)
db.from("users").where("name", "ismail").rows()
db.from("users").select("name").order("name").limit(20).rows()await db.from('users')
.insert({'email': 'Alice@Example.com', 'name': 'İsmail'});
await db.from('users').where('email', 'alice@example.COM').rows();
await db.from('users').insert({'email': 'ALICE@example.com'});
await db.from('users')
.insert({'email': 'alice@example.com'}, ifAbsent: true);
await db.from('users').where('name', 'ismail').rows();
await db.from('users').select(['name']).order('name').limit(20).rows();put users {email: $1, name: $2}
-- ["Alice@Example.com", "İsmail"]
get users where email = $1
-- ["alice@example.COM"]
put users {email: $1}
-- ["ALICE@example.com"]
put users {email: $1} if absent
-- ["alice@example.com"]
get users where name = $1
-- ["ismail"]
get users select name order name asc limit $1
-- [20]- Everything that compares it folds it:
=,!=,in,<and>,~;@hash,@unique,@sortedand an index over several fields;order,group,count(distinct …),facet,minandmax; alookupon it; a scoped token's rules over it. - It is kept as written. A row reads back as it was put. A group or a facet value is one fold, shown as the first row holding it — in id order — wrote it.
- Unicode's full case folding:
ß,ẞ,SSandssare one, a finalςisσ,fiisfi. Incollate tr, Turkish's:Iis the capital ofı,İofi. - Case alone. Accents, width and how a text is composed are not
case:
résuméis notresume,Aisaand notA, andéas one code point is noteand a combining accent. - A text field's. Not a list's or a
jsonfield's. Madenocaseor no longer, a field is built again — the schema check'srebuild— which a@uniqueone refuses when two of its values would become one.
How the case fold is made and held
The fold is Unicode's full case folding, the C and
F mappings of CaseFolding.txt, which Python's
str.casefold() is too, and it is held to that at every code
point (crates/fenec-core/tests/nocase.rs reads Python's own
fold of each). Full rather than simple folding, which maps one code point to
one: simple folding keeps straße apart from
STRASSE, which ICU's case-insensitive comparison and every
German reader find equal. A field in collate tr folds by
Turkish's T mappings over it, as a Turkish reader expects:
nocase | collate tr nocase | |
|---|---|---|
Alice, ALICE, alice | one | one |
straße, STRASSE, STRAẞE | one | one |
ΟΔΟΣ, οδος, οδοσ | one | one |
ᏣᎳᎩ, ꮳꮃꭹ (Cherokee) | one | one |
ISTANBUL, istanbul | one | apart |
ISTANBUL, ıstanbul | apart | one |
İstanbul, istanbul | apart: İ folds to i and a combining dot, as its decomposed spelling does | one |
ABC, abc / abc | one / apart | one / apart |
résumé, resume; é, e+U+0301 | apart | apart |
A nocase field in a collation orders by the collation over
the folds — collate tr nocase puts ılık before
İnce and finds ince equal to the second — so its
order and its equality agree: a collation tells every two different texts
apart, and so it ties exactly the texts that fold to one, which come out
in id order. Without a collation it orders by the folds' bytes. A query's
collate orders it in another collation, still by its folds.
~, which ignores case on any text by lowercasing both sides,
asks of a nocase field whether its fold holds the needle's:
straße ~ "SS".
Its indexes hold the folds: a @hash or @unique
bucket every spelling of one text, a @sorted index and an
index over several fields their order, so an equality, an
in, a range and an order … limit walk are answered
as the scan answers them, row for row — a test holds every filter, order,
group, facet and unique index over mixed-case data in ASCII, Turkish,
German, Greek, Cherokee and full width to a twin collection holding Python's
folds in plain fields. A lookup finds a child by its key as the
child's field compares: a nocase one by the parent's value's
fold. The schema writes the word into the file (a binary from before refuses
it), describe, GET /_schema?as=fenecql and the
declared schema carry it, a replica — a server's or a synced page's —
compares as the primary does, and fenec import takes
PostgreSQL's citext and SQLite's COLLATE NOCASE
as it.
What folding costs, over 100 000 rows (make nocase-bench:
a plain field holding one spelling and asked it, a nocase one
holding a mix and asked another; names a third past ASCII):
| 100 000 rows | plain | nocase |
|---|---|---|
a put, asking @unique | 1 209 ns | 1 200 ns |
an email by @unique | 1.08 µs | 1.16 µs |
a city's rows counted by @hash | 3.15 µs | 3.27 µs |
| a name by the scan | 5.47 ms | 6.79 ms |
a name's part by ~ | 12.7 ms | 10.4 ms |
| an order by name, a page, by the scan | 8.48 ms | 16.2 ms |
rows by city, group | 7.22 ms | 7.23 ms |
| a facet of names by the scan | 6.89 ms | 8.32 ms |
in a list of 10 names, by the scan | 6.90 ms | 11.9 ms |
An index lookup folds the one value it is asked. A scan folds a row only
as far as it differs from the value — mostly its first character — with
nothing allocated; ~ folds it whole, and still comes out
quicker than lowercasing both sides. An order folds each row's key once,
then sorts the folds; a @sorted index holds them folded and is
walked instead. The fold is the standard library's lowercase mapping, and
for the 297 code points whose fold is not their lowercase — 41 runs — its
lowercase, uppercase and lowercase again: no table of its own beyond those
runs. The browser module grew 11.5 KB, 5.0 KB gzip, 3.7 KB brotli — the
engine's indexes, filters, groups, facets and orders 6.4 KB of it, the fold
and its comparisons 2.1 — and a collection without a nocase
field reads and writes as it did.
Changing a collection
A collection takes a field, lets one go, or renames one, and none of it rewrites a document: it is as quick over a million documents as over none.
alter collection orders add field note text
alter collection orders add field code text @unique
alter collection orders drop field note
alter collection orders rename field total to amount- A field added reads
nullin every document written before it, and cannot berequired. - A field dropped is gone from every read and write, and its
index with it.
compactgives its space back. - A type does not change in place. Add a field of the new type,
setit from the old one, and drop the old one.
What a field change does to stored documents
A document is its values one after another in field order, so what a
change costs is what it does to that order. A field added goes last: a
document written before it ends before the field's place, and reads
null there. A rename is the schema alone. A field dropped
leaves its place in the documents written before the drop — skipped on
read, written as null after it, refused by name everywhere —
and compact writes every document without it, copying each
other value as its bytes stand. A field added under a dropped one's name is
a new field: the old values stay in the dropped place, and the new field
reads null until it is written. A change of type is the one
thing that would rewrite every document under the write lock, and is not
done.
Each is a write as a create index is: one of a block, put
back by its ROLLBACK with the writes after it, seen by a
subscriber as a schema change, applied by a replica, carried by the change
stream (op: "alter", the documents after it read by the
schema it made). In the file it is record kind 12, and a dropped place a
nameless field of type tag 12, both of which a binary from before them
refuses rather than read the fields after a dropped one a place early. The
index of a field added is built over the documents there, every one of them
null; a dropped field's goes, and a renamed one's goes with
the name, a vector field's graph included.
Measured over a million rows in memory: an add, a rename and a drop take
0.025, 0.021 and 0.004 ms. Reading a field past where a document ends costs
nothing a scan can measure: five scans of the million took 21.2 to 39.1 ms
before the feature and 21.6 to 39.3 after, from 1.7% slower to 1.7% faster
by the scan — as the same build with the reads written as before was,
which makes it where the code lands, not what it does — and the browser
module's scan of 20 000 rows 1.70 ms against 1.71. A compact that takes
a dropped field out took 95 ms against 56 for one with none to take: it
writes each document again rather than copy it. On a server it holds the
write lock, where a compact otherwise runs beside the database.
Inspecting the database
collections
describe articles
compact articlescompact drops deleted data and rewrites the file.
What compact leaves in place
compact drops the dead records and rewrites the file. The
indexes stand — the documents are the same ones, and every index is keyed by
id — except a vector graph holding tombstones, which is rebuilt: nothing else
takes a deleted or rewritten document's node out of it. See
Limits.