TessariDBDocs
  • 0.34.2-betalatest
  • 0.34.1-beta
  • 0.34.0-beta
  • 0.33.4-beta
  • 0.33.3-beta
  • 0.33.2-beta
  • 0.33.1-beta
  • 0.33.0-beta
  • 0.32.0-beta

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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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.

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenanswers
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

writtenmeasures
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

writtentrue 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

writtenanswers
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.

Functions — TessariDB