Functions
Every function the language has, by family — and what happens when one meets a value that is not there.
A function call is an ordinary expression. It stands wherever a value stands: in
a projection, in a condition, in an ORDER BY, inside another call.
Every name is written group::name, so a function can never collide with a
field name and the set stays groupable. This page is the whole set — a test
in the engine derives these names from the function table itself and fails when
one is missing from the specification the page is written from, in both
directions: a function that is not here, and a name here the engine does not
have.
Strings
| written | answers |
|---|---|
string::len(text) | how many characters — characters, not bytes |
string::lower(text) | lowercased |
string::upper(text) | uppercased |
string::trim(text) | without leading or trailing whitespace |
string::concat(a, b) | the two joined — exactly two, and it nests |
string::split(text, separator) | the pieces, as an array |
string::slice(text, start, count) | count characters from start, counted from zero |
string::lines(text, start, count) | count lines from start, counted from zero |
string::replace(text, from, to) | every occurrence of from replaced |
string::contains(text, needle) | whether the needle occurs anywhere — text holding text, where CONTAINS is an array holding a value |
string::starts_with(text, prefix) | whether it begins with the prefix — a prefix, not a LIKE pattern, so % and _ are ordinary characters |
string::ends_with(text, suffix) | the other end |
string::index_of(text, needle) | where it first occurs, counted in characters — none when it does not, never -1 |
string::reverse(text) | the characters back to front — characters, because reversing UTF-8 bytes does not produce text |
string::trim_start(text) | whitespace from the front only |
string::trim_end(text) | whitespace from the end only |
DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE orders;
USE DATABASE orders;
DEFINE COLLECTION people;
CREATE people:1 = { name: ' Ada ', city: 'Paris', path: 'a/b/c' };
SELECT string::upper(string::trim(name)) AS shouted FROM people:1;
SELECT string::concat(string::trim(name), ' of Paris') AS who FROM people:1;
SELECT string::split(path, '/') AS parts FROM people:1;
SELECT string::slice(city, 0, 3) AS short FROM people:1;
SELECT string::replace(path, '/', '.') AS dotted FROM people:1;
string::concat takes exactly two arguments. Joining three pieces nests, which
is verbose and is the honest shape: a variadic call would have to decide what an
absent argument in the middle means, and every answer to that is a surprise to
somebody.
string::lines is how a long body is read a piece at a time. It counts lines
where string::slice counts characters, and that is the point: paging a
document by character means knowing the offset where line 200 begins, and the
only way to learn it is to read the whole text — which is the cost the function
exists to avoid. Two adjacent windows reconstruct the document.
DEFINE COLLECTION notes;
CREATE notes:1 = { body: 'first\nsecond\nthird\nfourth' };
SELECT string::lines(body, 0, 2) AS head FROM notes:1;
SELECT string::lines(body, 2, 2) AS rest FROM notes:1;
It answers one text rather than an array — the array of lines is
string::split(text, '\n'), and what was missing was the window. A \r\n
document does not leak a carriage return, and a text ending in a newline does
not grow an empty last line for the count to spend.
It is a projection, so it shrinks what comes back, not what the node reads: the record is decoded whole and the window is taken from it. That is worth knowing before reaching for it as a way to make a large table cheaper to scan — it makes a large body cheaper to receive.
There is no string::contains, because LIKE '%x%' says it. That is the rule
the whole set is held to — a function earns a place when it cannot be
expressed by what the language already has.
Arrays
| written | answers |
|---|---|
array::len(items) | how many elements |
array::first(items) | the first, or an absence if there is none |
array::last(items) | the last, or an absence |
array::distinct(items) | without duplicates |
array::sort(items) | in the language's one order across types |
array::reverse(items) | back to front |
array::flatten(items) | one level of nesting removed |
array::join(items, separator) | the elements as one string |
array::slice(items, start, count) | count elements from start |
array::append(items, value) | the array with one more value at the end — appending an array adds one element, where concat would add its contents |
array::concat(a, b) | one array holding both, in order |
array::index_of(items, value) | the first position holding it, or none |
array::min(items) | the smallest, in the value system's order — none over nothing. This folds one array in one record; min over rows is the aggregate |
array::max(items) | the largest, on the same terms |
array::sum(items) | the numbers added; over nothing, zero — which is what the sum aggregate answers over no rows |
CREATE people:2 = { name: 'grace', tags: ['urgent', 'old', 'urgent'] };
SELECT array::len(tags) AS n, array::first(tags) AS top, array::last(tags) AS newest FROM people:2;
SELECT array::distinct(tags) AS unique_tags, array::sort(tags) AS ordered FROM people:2;
SELECT array::reverse(tags) AS backwards, array::join(tags, ', ') AS listed FROM people:2;
SELECT array::slice(tags, 0, 2) AS pair FROM people:2;
CREATE people:4 = { nested: [[1, 2], [3]] };
SELECT array::flatten(nested) AS flat FROM people:4;
array::last is the clearest case for the earns-a-place rule: a path takes a
literal position and there is no length to subtract from, so "the last element"
is otherwise unsayable. There is no array::contains, because CONTAINS says
it.
Objects
| written | answers |
|---|---|
object::keys(o) | the field names, sorted |
object::values(o) | the values, in the same order as the keys |
object::len(o) | how many fields |
object::entries(o) | one two-element array per field, name first |
object::has(o, name) | whether the field is there at all — different from comparing it against none, because a field explicitly holding none is present |
object::merge(a, b) | both objects' fields, b winning a collision |
CREATE people:3 = { name: 'alan', meta: { team: 'core', level: 3 } };
SELECT object::keys(meta) AS names, object::len(meta) AS n FROM people:3;
SELECT object::values(meta) AS held FROM people:3;
Useful over a field whose shape differs between records — which is the normal case for a table nobody has declared.
Numbers
| written | answers |
|---|---|
math::abs(n) | without its sign |
math::floor(n) | rounded down |
math::ceil(n) | rounded up |
math::round(n) | rounded to the nearest, half away from zero |
math::sqrt(n) | the square root |
math::pow(base, exponent) | base raised to exponent |
math::min(a, b) | the smaller of two numbers — two numbers, not a column: min over rows is the aggregate |
math::max(a, b) | the larger, on the same terms |
math::exp(number) | e raised to it |
math::ln(number) | the natural logarithm — none at zero and below, rather than -∞ or a NaN |
math::sign(number) | -1, 0 or 1 |
math::trunc(number) | the whole part, toward zero — which differs from math::floor only for negatives |
DEFINE COLLECTION readings;
CREATE readings:1 = { v: -3.7, area: 9 };
SELECT math::abs(v) AS size, math::floor(v) AS down, math::ceil(v) AS up FROM readings:1;
SELECT math::round(v) AS near, math::sqrt(area) AS side, math::pow(area, 2) AS squared FROM readings:1;
Overflow and division by zero are failures, not values — including for floats, where the hardware would happily hand back an infinity. A wrapped integer written into a record is a number nobody meant, and by the time anyone notices it is stored.
Time
| written | answers |
|---|---|
time::now() | the moment the statement ran |
time::bucket(at, span) | at rounded down to a boundary of span |
time::year(at) | the calendar year |
time::month(at) | the month, 1–12 |
time::day(at) | the day of the month |
time::hour(at) | the hour, 0–23 |
time::minute(at) | the minute |
time::second(at) | the second |
time::unix(at) | seconds since the epoch |
time::from_unix(seconds) | the instant those seconds name |
duration::from_secs(n) | the duration of n seconds; a decimal keeps its fraction |
DEFINE COLLECTION hits;
CREATE hits:1 = { at: datetime '2026-08-26T10:05:00Z', n: 3 };
CREATE hits = { at: datetime '2026-08-26T10:41:00Z', n: 4 };
CREATE hits = { at: datetime '2026-08-26T11:02:00Z', n: 5 };
SELECT time::bucket(at, 1h) AS hour, sum(n) AS total FROM hits GROUP BY time::bucket(at, 1h);
SELECT time::year(at) AS y, time::month(at) AS m, time::day(at) AS d FROM hits:1;
SELECT time::hour(at) AS h, time::minute(at) AS min, time::second(at) AS s FROM hits:1;
SELECT time::unix(at) AS epoch FROM hits:1;
SELECT time::from_unix(1787738700) AS at FROM hits:1;
SELECT at + duration::from_secs(90) AS later, at - 1h AS earlier FROM hits:1;
RETURN datetime '2026-08-26T11:00:00Z' - datetime '2026-08-26T10:05:00Z';
From 0.33.4-beta an instant takes arithmetic: datetime ± duration is an
instant and datetime − datetime a duration. A result past what a datetime or a
duration can hold is refused rather than wrapped.
time::bucket is what makes a series query one statement rather than a
date-formatting exercise: the hour boundary is a value, so grouping by it needs
no new syntax and no separate time-series table.
The accessors read the instant in UTC. There is no timezone argument, because a timezone is a property of a presentation and not of an instant, and a function that took one would make the same stored value answer differently depending on who asked.
time::now() may not appear in an ASSERT, because an assertion is checked
where validation lives and its verdict must not depend on when the check ran.
See Definitions.
Casts
| written | answers |
|---|---|
type::bool(v) | the value as a boolean |
type::int(v) | as a signed integer |
type::float(v) | as a binary float |
type::string(v) | as text |
type::datetime(v) | as an instant |
type::uuid(v) | as a identifier |
CREATE readings:2 = { raw: '42', when: '2026-01-01T00:00:00Z', flag: 'true' };
SELECT type::int(raw) AS n, type::float(raw) AS f FROM readings:2;
SELECT type::datetime(when) AS at, type::bool(flag) AS enabled FROM readings:2;
SELECT type::string(raw) AS text FROM readings:2;
The casts are named after the kinds they produce, and they exist because text
arriving from outside is text: nothing else in the language turns '42' into a
number.
Two are deliberately absent. type::number names three numeric kinds
without choosing one, so it could only mean "whichever kind it already was",
which is not a conversion. type::decimal is deferred rather than refused —
a cast producing an exact decimal from a float has to say what it does with a
value no decimal holds exactly, and that question deserves its own answer.
Introspection
| written | answers |
|---|---|
type::of(value) | the name of the value's type |
SELECT type::of(tags) AS kind FROM people:2;
Useful over a field with no declared type, where different records legitimately
hold different things. The type of an absence is none — this is one of the few
functions with a real answer for a missing field rather than a propagated one.
| written | answers |
|---|---|
session::context() | { node, namespace, database } — the node this session is talking to, and the namespace and database it selected, null where it selected none |
RETURN session::context();
It is what a client following a redirect
asks at both ends: on the node it leaves, which tenancy to select again, and on
the node it reaches, whether that is the node the redirect named. Any session may
ask, a reader with one grant included — the node's identity is what every
redirect already discloses, and the names are the session's own USE. From
0.20.0-beta.
Identifiers
| written | answers |
|---|---|
rand::uuid() | a fresh identifier, per record |
DEFINE COLLECTION events;
DEFINE FIELD trace ON events TYPE uuid DEFAULT rand::uuid();
CREATE events = { name: 'opened' };
Per record, not per statement. A generated identity that folded like a constant would hand every record of a bulk insert the same one — not a compile error, not a test failure, and not visible until two records that should differ do not.
Digests
| written | answers |
|---|---|
crypto::sha256(text) | the digest, as lowercase hex |
crypto::sha512(text) | the same, wider |
crypto::md5(text) | the MD5 digest, thirty-two lowercase hex characters — a checksum, and broken as anything else |
crypto::sha1(text) | the SHA-1 digest, forty lowercase hex characters — a checksum on the same terms |
SELECT crypto::sha256(name) AS fingerprint FROM people:1;
SELECT crypto::sha512(name) AS wide FROM people:1;
These are digests, not password hashing. A fast digest is the wrong tool for
a credential, which is the one property a stored credential must not have;
DEFINE USER hashes with something built for it. See
Users.
Encoding
| written | answers |
|---|---|
encoding::base64(bytes) | the bytes as standard base64 text, padded — RFC 4648 §4, the +/ alphabet |
encoding::base64_decode(text) | the bytes that text encodes, or none when it encodes none |
encoding::hex(bytes) | the bytes as lowercase hexadecimal text |
encoding::hex_decode(text) | the bytes that text spells, or none — either case of letter is read |
JSON
| written | answers |
|---|---|
json::parse(text) | the value that JSON text spells, or none when it spells none |
json::encode(value) | the value as compact JSON text |
RETURN json::parse('{"b": 2, "a": [1, "x", null]}'); -- { a: [1, 'x', NULL], b: 2 }
RETURN json::encode({ total: dec 12.34, at: NONE }); -- '{"total":"12.34"}'
One mapping, the store's own: json::parse reads what a Kafka consumer reads — a
number written without a fraction is an exact int, the last duplicate key wins,
JSON's null is NULL — and json::encode writes what the HTTP surface writes:
keys in name order (the order a document is held in), a decimal quoted so it does
not become a double, a record reference by its table's name, a NONE field left
out. Text that is not one JSON value answers none, like a decoder above.
Distances
| written | measures |
|---|---|
vector::euclidean(a, b) | straight-line distance |
vector::cosine(a, b) | the angle, ignoring magnitude |
vector::dot(a, b) | the dot product |
See Vectors, and note the one place a word in the statement decides whether the answer is exact.
Shapes
| written | true when |
|---|---|
geo::intersects(a, b) | they meet at all, boundary included |
geo::disjoint(a, b) | they do not meet |
geo::within(a, b) | a is inside b, boundary excluded |
geo::covered_by(a, b) | a is inside b, boundary included |
geo::contains(a, b) | the mirror of within |
geo::covers(a, b) | the mirror of covered_by |
geo::touches(a, b) | boundaries meet and interiors do not |
geo::equals(a, b) | the same ground, however written |
geo::area(a) | the ground covered |
geo::distance(a, b) | metres between a position and a position, path or area — to the shape's nearest point, zero when it covers the position |
geo::cell(position, level) | the spatial index's own cell holding the position at that level, 0 to 32, as a polygon — for counting a map by cell |
See Geometry for the boundary rules, which is where every geospatial mistake actually lives.
Ranking
| written | answers |
|---|---|
search::score(field, terms) | how well this record matches, against its collection |
search::ranks() | in the projection of a read ordered by FUSE (…), where each branch placed the record — none beyond a branch's depth (Several orders at once) |
search::highlight(field) | where in the field's text this read's own query matched — an array of { start, end } byte ranges, ordered by position |
search::explain(field, terms) | the score search::score answers, with what it was made of — per word, how often the record held it, how many records do, its weight and its contribution; the contributions add up to the score exactly |
search::score() | in a FROM SEARCH, the record's BM25F score across the search's fields (One search over several tables) |
search::table_name() | in a FROM SEARCH, the table the record came from |
search::snippet() | in a FROM SEARCH, { field, start, end }: the window of the record's SNIPPET fields holding the most query words |
Refused over a field with no search index — a score measures a record against a collection, and there is none without one. See Full-text search.
search::highlight takes the field alone: the terms are whatever this
statement already asked of that field, so a second copy of the query cannot
disagree with the WHERE about what matched.
An absence narrows; a wrong type refuses
This is the rule that ties the whole page together, and it is worth stating once rather than in every row.
A call over a field a record does not have answers with an absence, and an absence in a condition is a no — so the record drops out of the read instead of failing it:
CREATE people = { name: 'alan' };
SELECT * FROM people WHERE array::len(tags) > 0;
alan has no tags, and refusing that would let one record missing one field
fail an entire read.
Four functions have a real answer for an absence rather than a propagated one:
type::of answers none, the three vector distances answer +∞ — a distance
to something that is not there is unbounded — and search::score answers 0,
because a record holding none of the query's words scores zero.
But a value that is present and of the wrong type is a mistake, and says so
— WrongArgument. The two cases are different and are treated differently:
missing data is a state of the world, and the wrong type is a bug in the
statement.
Getting the number of arguments wrong is caught before anything runs, with
WrongArity, because the set of functions is known when a statement is read. A
name the language does not have is NoSuchFunction.