Relix

Programming guide

The session

A Relix session holds what a script would declare — sources, connections, views, functions — together with the bindings that connect them outward: a live DataSource, a catalog, a clock. It is the thing a program keeps.

A session is meant to be long-lived. The declarations it accumulates and the connections it pools are both worth keeping, so the usual shape is a field created at startup and closed at shutdown rather than a try block per query.

Relix relix = Relix.open();

Relix.open() is the everything-default session: no database bound, no catalog, the system clock, and the bundled functions and connectors installed. Relix.builder() is the same thing with the knobs exposed.

Declaring things

define takes declarations and returns the session, so it chains. It accumulates: two calls are one session, not two.

Java
relix.define("""
        source Orders from csv("./orders.csv") { header: true, schema: {
            order_id: NUMBER, customer_id: NUMBER, status: STRING, amount: NUMBER } };
        """);

relix.define("OpenOrders := { σ status = 'OPEN' (Orders) };");

Relation openOrders = relix.relation("OpenOrders");
System.out.println(openOrders.render() + " — " + openOrders.schema().columns().size() + " columns");
Result
OpenOrders — 4 columns

The view resolves against the source declared in the previous call, and its heading is the source's — one session, not two.

define refuses a query statement, and the refusal is the useful part: a query is not a declaration, it is a result, and script is what hands those back.

Java
try {
    relix.define("query { σ amount > 10 (Orders) };");
} catch (RelixException e) {
    System.out.println(e.getMessage());
}
Result
define() takes declarations; this text has 1 query statement(s). Use script(...) to get a Relation for each.

Running a whole script

script is what a .relix file goes through. It installs the declarations and returns one Relation per query statement, in source order:

Java
List<Relation> results = relix.script("""
        Shipped := { σ status = 'SHIPPED' (Orders) };
        query Shipped;
        query { π order_id (Orders) };
        """);

for (Relation result : results) {
    System.out.println(result.label().orElse("(unlabelled)") + " → " + result.render());
}
Result
Shipped → Shipped
<expression 2> → π order_id (Orders)

label() is how a result names itself: the name for a query Shipped;, and a positional <expression N> for an unnamed one. It is the same rule the engine's own event feed uses, so a printed heading and a traced event agree about what to call a result.

Reading the session back

definitions() renders the session as .relix text, and statements() gives the same content as AST statements:

Java
System.out.println(relix.definitions());
System.out.println(relix.statements().size() + " statements");
Result
source Orders from csv("./orders.csv") { header: true, schema: { order_id: number, customer_id: number, status: string, amount: number } };
OpenOrders := { σ status = "OPEN" (Orders) };
Shipped := { σ status = "SHIPPED" (Orders) };

3 statements

A session that renders back as text is a session that can be saved, diffed, or handed to someone as a reproduction — which is why the printer exists at all.

Errors, and the one place they are not thrown

Everything the facade refuses, it refuses by throwing RelixException. A typo in a query your own program wrote is a bug, and a bug should stop the program:

Java
try {
    relix.relation("σ status = 'OPEN' (NoSuchRelation)");
} catch (RelixException e) {
    System.out.println(e.getMessage());
}
Result
Undefined relation: 'NoSuchRelation'

A query assembled from someone else's input is a different situation. There an error is an expected outcome to render, not a defect to raise, and validate returns the diagnostics as data instead:

Java
import com.darkcollective.relix.embed.Diagnostic;

for (Diagnostic problem : relix.validate("Bad := { σ nope = 1 (Orders) };")) {
    System.out.println(problem.severity() + ": " + problem.message());
}
Result
ERROR: Selection σ: attribute 'nope' not found in input schema (available: order_id, customer_id, status, amount)

Check severity() rather than treating every diagnostic as a refusal — a warning is a diagnostic too, and a caller that stops on one stops on things that were fine.

Java
System.out.println("errors only: "
        + relix.validate("Bad := { σ nope = 1 (Orders) };").stream()
                .filter(Diagnostic::isError)
                .count());
Result
errors only: 1

validate also changes nothing: the text is analysed and discarded, so a session is never half-modified by a script that turned out not to analyse.

One thing does not come back as RelixException. A failure that only appears once rows are moving — a fixpoint that will not converge, a backend that rejects a query — arrives as the engine's own exception, because flattening every cause into one type would take the diagnosis away. Catch RuntimeException around a terminal if you mean to catch everything.

Configuring a session

Relix.builder() exposes what a session can be given. Each is a binding to the outside world rather than a tuning parameter:

MethodWhat it supplies
jdbc(name, dataSource)a live database handle, under a connection name
catalog(provider)where schemas and statistics come from — live, or a snapshot
clock(clock)what NOW() and CURRENT_DATE read
baseDirectory(path)what a relative file path in a source resolves against
functions(library)functions of your own, merged with the bundled ones
connector(connector)a backend of your own
scriptLoader(loader)what an import statement resolves through
maxFixpointRounds(n)the recursion safety valve
provisioners(drivers, connectors)permission to download a missing driver or plugin
relationships(graph)join edges the schema does not carry
sessionEvents(events)the feed relix.events reports
allowUnresolved()compose against names no catalog can resolve

The clock is the one worth showing, because it is what makes a query over "now" reproducible in a test:

Java
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

try (Relix pinned = Relix.builder()
        .clock(Clock.fixed(Instant.parse("2026-03-01T12:00:00Z"), ZoneOffset.UTC))
        .build()) {

    System.out.println(pinned.relation("π NOW() → t, CURRENT_DATE() → d (UNIT)").toList());
}
Result
[(t=2026-03-01T12:00:00Z, d=2026-03-01)]

UNIT there is the one-row, no-column relation — the algebra's way of saying "evaluate this expression once", which is what a scalar query is.

Two defaults are deliberate and worth knowing about, because both are cases where a library should not surprise its caller:

Concurrency

A session is meant to be long-lived, and in a server that usually means one field shared by every request thread. So the division matters: the session is not thread-safe, and the relations it hands out are.

define mutates the session and the analysis is cached, so two threads declaring into one session — or one declaring while another mints a relation — race. Declare from one thread, at startup, before the session is shared.

A Relation is a different thing. It pins the model it was analysed against, which is what makes it a value rather than a view onto the session's mutable state, and that is exactly what makes it safe to hold, cache and hand to another thread. Draining two relations from two threads is in contract:

Java
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;

// Declared once, before anything is shared — which is the rule, not the example's
// convenience.
relix.table("Payments", List.of(
        Map.of("status", "OPEN"), Map.of("status", "OPEN"), Map.of("status", "PAID")));

Relation openPayments = relix.relation("σ status = 'OPEN' (Payments)");
Relation paid = relix.relation("σ status = 'PAID' (Payments)");

try (ExecutorService threads = Executors.newFixedThreadPool(2)) {
    var counts = List.of(threads.submit(openPayments::count), threads.submit(paid::count));
    for (var pending : counts) {
        System.out.println(pending.get());
    }
}
Result
2
1

Both relations were minted before the threads started, and each carries the analysis it needs. Nothing in the two terminals coordinates, and nothing has to.

Two consequences are worth spelling out, because they are what the rule costs in practice:

If a request thread genuinely needs to declare something of its own, give it its own session. A session is cheap: what is expensive is the database connection pool, and a DataSource you bind is shared rather than copied, so several sessions over one handle pool independently against connections you still own.

Closing

Closing a session releases the database connections it opened. The DataSource you handed it is yours and is left alone, and everything else it holds is ordinary garbage:

Java
relix.close();

// Still a value: building one and rendering it never needed the session's bindings.
System.out.println(relix.relation("OpenOrders").render());

try {
    relix.relation("Orders").toList();
} catch (RelixException e) {
    System.out.println(e.getMessage());
}
Result
OpenOrders
this session is closed

That is the division the whole API is built on, seen at the end of a session's life: a relation is a value, and only the execution terminals need anything from the outside world.