A join adds a second row source to a read. Name the models, state the condition, and project the columns you want out of the combined row.
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
total: float
}
fn orders_with_names(): []{ order_id: int, customer_name: string } ! QueryError {
return FROM Order
JOIN Customer ON Order.customer_id == Customer.id
SELECT { order_id: Order.id, customer_name: Customer.name }?
}
fn main(): void {
match orders_with_names() {
Ok(rows) => {
for row in rows {
println("order ${row.order_id} — ${row.customer_name}")
}
}
Err(e) => println("query failed")
}
}Three rules do most of the work here:
- The
ONexpression is checked in a scope containing both sources, soOrder.customer_idandCustomer.idare columns, not Atoll locals. - Qualify every field once two sources are in scope.
idalone is ambiguous when both models have one. - The row type is the projection.
[]{ order_id: int, customer_name: string }is an anonymous record list — write it out to pin the contract, or let it infer.
A join needs a projection
A joined row is neither an Order nor a Customer, so there is no carrier for
it unless you say what the result row looks like. Omitting SELECT is a
compile error, not a silent SELECT *:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
fn broken(): void ! QueryError {
rows := FROM Order
JOIN Customer ON Order.customer_id == Customer.id?
println("${rows.len()}")
}That reports ATOLL3244: this FROM query cannot be lowered to SQL. Add a
SELECT { ... } naming the columns and it compiles.
Qualified columns need an explicit name
Inside a SELECT { ... }, bare-field shorthand accepts only a plain
identifier. Order.id is a path, not a name, so the projection field has to be
spelled alias: Qualifier.column:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
fn broken(): void ! QueryError {
// `Order.id` and `Customer.name` need names: `id: Order.id`, ...
rows := FROM Order
JOIN Customer ON Order.customer_id == Customer.id
SELECT { Order.id, Customer.name }?
println("${rows.len()}")
}The parser reads Order as the field name and then trips on the ., so you
get a cascade — ATOLL1006 Expected '}' followed by resolution errors. Naming
each field fixes all of them at once.
Join kinds
Three condition-bearing spellings exist, plus the unconditioned cross join:
| Atoll | Meaning |
|---|---|
JOIN Model ON cond |
inner join — matching pairs only |
LEFT JOIN Model ON cond |
keeps every left row |
RIGHT JOIN Model ON cond |
keeps every right row |
CROSS JOIN Model |
every pair, no condition |
There is no INNER JOIN keyword. JOIN already means inner, and INNER
parses as an ordinary identifier — which then fails to resolve:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
fn broken(): void ! QueryError {
rows := FROM Order
INNER JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Order.id }?
println("${rows.len()}")
}Here are all four forms side by side:
schema shop
model Customer {
@id
id: int
name: string
region: string
}
model Order {
@id
id: int
customer_id: int
total: float
}
// Only orders that have a customer row.
fn matched(): []{ id: int, name: string } ! QueryError {
return FROM Order
JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Order.id, name: Customer.name }?
}
// Every order, even one whose customer row is missing.
fn all_orders(): []{ id: int, name: string } ! QueryError {
return FROM Order
LEFT JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Order.id, name: Customer.name }?
}
// Every customer, even one with no orders.
fn all_customers(): []{ id: int, name: string } ! QueryError {
return FROM Order
RIGHT JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Customer.id, name: Customer.name }?
}
// Every (order, customer) pair — a deliberate cartesian product.
fn every_pair(): []{ id: int, region: string } ! QueryError {
return FROM Order
CROSS JOIN Customer
SELECT { id: Order.id, region: Customer.region }?
}
fn main(): void {
match matched() { Ok(rows) => println("matched ${rows.len()}") Err(e) => println("failed") }
match all_orders() { Ok(rows) => println("orders ${rows.len()}") Err(e) => println("failed") }
match all_customers() { Ok(rows) => println("customers ${rows.len()}") Err(e) => println("failed") }
match every_pair() { Ok(rows) => println("pairs ${rows.len()}") Err(e) => println("failed") }
}CROSS JOIN takes no ON clause; its result is left_rows * right_rows rows.
Reach for it only when that is what you want.
Projection types follow the column, not the join
A projection field has exactly the type its source column was declared with.
A LEFT JOIN does not widen the right-hand fields to T?, so annotating
the result that way is a type error:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
// `Customer.name` is `string`; the join does not make it `string?`.
fn broken(): []{ id: int, name: string? } ! QueryError {
return FROM Order
LEFT JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Order.id, name: Customer.name }?
}This is a real gap, not a design choice: an unmatched left row genuinely has no
right-hand value at runtime, but the type system does not yet model it. Today
the only way to get an optional projection field is to declare the column
optional on the model. Then the projection is optional too, and ?? reads
naturally:
schema shop
model Customer {
@id
id: int
name: string?
}
model Order {
@id
id: int
customer_id: int
}
fn with_optional_name(): []{ id: int, name: string? } ! QueryError {
return FROM Order
LEFT JOIN Customer ON Order.customer_id == Customer.id
SELECT { id: Order.id, name: Customer.name }?
}
fn main(): void {
match with_optional_name() {
Ok(rows) => {
for row in rows {
println("${row.id}: ${row.name ?? "(unknown customer)"}")
}
}
Err(e) => println("query failed")
}
}Filtering, grouping, and ordering a join
Every other read clause works over the joined row. Qualify the columns exactly
as in ON, and compare booleans explicitly — a bare qualified boolean column
is not currently recognised as a WHERE predicate on its own.
schema shop
model Customer {
@id
id: int
name: string
active: bool
}
model Order {
@id
id: int
customer_id: int
total: float
}
fn big_active_orders(
floor: float,
): []{ id: int, name: string, total: float } ! QueryError {
return FROM Order
JOIN Customer ON Order.customer_id == Customer.id
WHERE Customer.active == true && Order.total >= floor
SELECT { id: Order.id, name: Customer.name, total: Order.total }
ORDER BY Order.total DESC
LIMIT 25?
}
fn main(): void {
match big_active_orders(250.0) {
Ok(rows) => {
for row in rows {
println("${row.name} spent ${row.total}")
}
}
Err(e) => println("query failed")
}
}Note the clause order: SELECT comes before ORDER BY and LIMIT, matching
the order the query grammar reads them in.
Grouping works over qualified columns too, which is how you collapse a one-to-many join back down to one row per parent:
schema shop
model Customer {
@id
id: int
name: string
region: string
}
model Order {
@id
id: int
customer_id: int
total: float
}
fn revenue_by_region(): []{ region: string, revenue: float } ! QueryError {
return FROM Order
JOIN Customer ON Order.customer_id == Customer.id
GROUP BY Customer.region
SELECT { region: Customer.region, revenue: sum(Order.total) }?
}
fn main(): void {
match revenue_by_region() {
Ok(rows) => {
for row in rows {
println("${row.region}: ${row.revenue}")
}
}
Err(e) => println("query failed")
}
}Cardinality
A join changes the row count, and the compiler will not warn you about it. If
each order has exactly one customer, the inner join above returns one row per
order. If each customer has many orders, joining from Customer duplicates
the customer’s columns once per order — so a sum() over a customer column
would count it several times.
| Form | Unmatched left rows | Unmatched right rows |
|---|---|---|
JOIN |
dropped | dropped |
LEFT JOIN |
kept, right columns null | dropped |
RIGHT JOIN |
dropped | kept, left columns null |
CROSS JOIN |
every pair | every pair |
Predicate placement matters for outer joins. A right-side condition written in
ON decides which right rows match while keeping every left row; the same
condition written in WHERE runs after the join and discards the null-extended
rows, quietly turning a left join into an inner one.
Relation joins
A BELONGS TO ... VIA relation on the model supplies the condition, so ON
can be omitted:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
total: float
BELONGS TO customer: Customer VIA customer_id
}
fn orders_with_names(): []{ id: int, name: string } ! QueryError {
return FROM Order
JOIN Customer
SELECT { id: Order.id, name: Customer.name }?
}
fn main(): void {
match orders_with_names() {
Ok(rows) => println("${rows.len()} orders")
Err(e) => println("query failed")
}
}Drop the BELONGS TO line and there is nothing left to synthesise the
condition from. The diagnostic is the generic backstop rather than anything
that mentions relations, so recognise it: a bare JOIN Model that reports
ATOLL3244 almost always means the relation is missing.
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
// No `BELONGS TO` on `Order`, so `JOIN Customer` has no condition to build.
fn broken(): []{ id: int, name: string } ! QueryError {
return FROM Order
JOIN Customer
SELECT { id: Order.id, name: Customer.name }?
}Write the ON clause explicitly whenever the relation is absent, or whenever
two declared relations could connect the same pair of models. A relation is
metadata for condition synthesis and DDL — declaring one never loads rows on
its own.
Derived tables
A parenthesised read can be joined as a source of its own. Give it a name with
AS, and its projected columns become that name’s columns:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
total: float
}
fn customers_with_totals(): []{ name: string, paid: float } ! QueryError {
return FROM Customer
JOIN (
FROM Order
GROUP BY customer_id
SELECT { customer_id, paid: sum(total) }
) AS totals
ON Customer.id == totals.customer_id
SELECT { name: Customer.name, paid: totals.paid }?
}
fn main(): void {
match customers_with_totals() {
Ok(rows) => {
for row in rows {
println("${row.name} paid ${row.paid}")
}
}
Err(e) => println("query failed")
}
}This is the standard fix for the duplication problem above: aggregate the many
side first, then join one row per parent. Only the derived query’s projected
names escape — a column the inner SELECT does not name is invisible outside
the parentheses.
The AS alias is required on a derived-table join. Plain model joins do not
take an alias; a model is referred to by its own name.
What the compiler rejects
A conditional join is rejected, because including or excluding a join changes row cardinality and cannot be expressed as a bound parameter:
schema shop
model Customer {
@id
id: int
name: string
}
model Order {
@id
id: int
customer_id: int
}
fn broken(with_names: bool): void ! QueryError {
rows := FROM Order
JOIN Customer ON Order.customer_id == Customer.id if with_names
SELECT { id: Order.id }?
println("${rows.len()}")
}That is ATOLL3242. When a caller genuinely needs two different row shapes,
write two queries and pick between them with an ordinary if.
Join order and index selection stay with the database planner. The compiler checks the shape of the join and the types flowing out of it; it makes no promise about the physical plan.
A worked example
schema shop
model Customer {
@id
id: int
name: string
region: string
}
model Order {
@id
id: int
customer_id: int
total: float
status: string
BELONGS TO customer: Customer VIA customer_id
}
/// One row per customer in `region`, with their settled lifetime spend.
/// Customers with no settled orders still appear.
fn spend_report(region: string): []{ name: string, spend: float } ! QueryError {
return FROM Customer
LEFT JOIN (
FROM Order
WHERE status == "settled"
GROUP BY customer_id
SELECT { customer_id, spend: sum(total) }
) AS settled
ON Customer.id == settled.customer_id
WHERE Customer.region == region
SELECT { name: Customer.name, spend: settled.spend }
ORDER BY Customer.name?
}
fn main(): void {
match spend_report("emea") {
Ok(rows) => {
mut total := 0.0
for row in rows {
println("${row.name}: ${row.spend}")
total = total + row.spend
}
println("region total ${total}")
}
Err(e) => println("spend report failed")
}
}Continue with Aggregates for GROUP BY and
HAVING in depth, and Projections for the row
shapes a SELECT can produce.