Skip to content

Frames

Keep SQL results host-resident and build lazy dataframe plans.

Updated View as Markdown

AS FRAME ends a read without materializing rows into the guest. The result stays in host-resident columnar storage and you get back a Frame[T] — a thin, generation-tagged handle:

schema travel

model Booking {
    @id
    id: int
    nights: int
    status: string
    created_at: int
}

fn recent_frame(cutoff: int): Frame[Booking] ! QueryError {
    return FROM Booking
        WHERE created_at >= cutoff
        AS FRAME?
}

fn main(): void {
    match recent_frame(0) {
        Ok(frame) => {
            println("${frame.count()} rows in ${frame.batches()} batch(es)")
            frame.release()
        }
        Err(e) => println("query failed")
    }
}

T is the row type used when rows are eventually materialized. Reading a whole model gives Frame[Booking]; adding a SELECT gives a frame over the projection’s anonymous record, so annotate it that way rather than reaching for a named struct:

schema travel

model Booking {
    @id
    id: int
    nights: int
    created_at: int
}

// A projected frame's row type is the anonymous record, not a struct you named.
fn narrow(cutoff: int): Frame[{ id: int, nights: int }] ! QueryError {
    return FROM Booking
        WHERE created_at >= cutoff
        SELECT { id, nights }
        AS FRAME?
}

fn main(): void {
    match narrow(0) {
        Ok(frame) => println("${frame.count()} projected rows")
        Err(e) => println("query failed")
    }
}

Engines

Two engines have distinct jobs:

Layer Engine Responsibility
Query datasource engine Execute FROM Model ...; DataFusion is one possible query engine
Manipulation Polars Execute every Df[T] operation after frame.df()

A DataFusion result bridges its Arrow batches into Polars without copying the columns. Results from Turso, PostgreSQL, and MySQL are converted from their host-side cell grids. The Atoll surface is identical in both cases.

Frame operations

count() and batches() read metadata only. collect() copies and decodes every row into guest memory; collect_batch(i) bounds that copy to one host batch, which is how you stream a result larger than guest memory:

schema travel

model Booking {
    @id
    id: int
    nights: int
}

fn frame_of(): Frame[Booking] ! QueryError {
    return FROM Booking AS FRAME?
}

/// Everything at once.
fn all_rows(): []Booking ! QueryError {
    frame := frame_of()?
    return frame.collect()?
}

/// One batch at a time — guest memory stays at one batch, not the whole result.
fn total_nights(): int ! QueryError {
    frame := frame_of()?
    mut nights := 0
    for i in 0..frame.batches() {
        rows := frame.collect_batch(i)?
        for row in rows {
            nights = nights + row.nights
        }
    }
    return nights
}

fn main(): void {
    match all_rows() { Ok(rows) => println("${rows.len()} rows") Err(e) => println("failed") }
    match total_nights() { Ok(n) => println("${n} nights in total") Err(e) => println("failed") }
}

collect() does not consume the frame — the handle stays valid afterwards. Dropping a frame releases its host entry, and release() does so early; release() is idempotent, and Drop runs on normal exit, error propagation, and cancellation alike.

Generation tagging makes a stale handle benign rather than dangerous: count() and batches() report 0, and collection returns QueryError. An out-of-range batch index is the same Err.

Lazy plans

frame.df() lifts a frame into Df[T], a lazy Polars plan. Deriving a plan scans nothing; a terminal executes it:

schema travel

model Booking {
    @id
    id: int
    nights: int
    status: string
}

fn frame_of(): Frame[Booking] ! QueryError {
    return FROM Booking AS FRAME?
}

fn longest_stays(): []Booking ! QueryError {
    frame := frame_of()?
    plan := frame.df()
        .filter(col("nights").gt(lit(3)))
        .sort("nights", true)
        .head(10)
    return plan.collect()?
}

fn main(): void {
    match longest_stays() {
        Ok(rows) => {
            for row in rows {
                println("booking ${row.id}: ${row.nights} nights")
            }
        }
        Err(e) => println("plan failed")
    }
}

collect() executes and copies rows into the guest. to_frame() executes into another host-resident Frame[T], which is what you want when the result feeds more host-side work rather than guest code.

Schema-preserving verbs — head, tail, slice, unique, sort, sort_by, filter — keep the plan at Df[T]. Schema-changing verbs take the new row type as an explicit parameter: select[R], with_columns[R], join[U, R], left_join[U, R], and group_by(...).agg[R](...).

schema travel

model Booking {
    @id
    id: int
    nights: int
    status: string
}

struct Pair {
    id: int
    nights: int
}

struct ByStatus {
    status: string
    nights: int
}

fn frame_of(): Frame[Booking] ! QueryError {
    return FROM Booking AS FRAME?
}

fn pairs(): []Pair ! QueryError {
    frame := frame_of()?
    return frame.df().select[Pair]([col("id"), col("nights")]).collect()?
}

fn nights_by_status(): []ByStatus ! QueryError {
    frame := frame_of()?
    return frame.df()
        .group_by([col("status")])
        .agg[ByStatus]([col("nights").sum().alias("nights")])
        .collect()?
}

fn main(): void {
    match pairs() { Ok(rows) => println("${rows.len()} pairs") Err(e) => println("failed") }
    match nights_by_status() {
        Ok(rows) => {
            for row in rows {
                println("${row.status}: ${row.nights} nights")
            }
        }
        Err(e) => println("failed")
    }
}

R is a plain row struct. Output columns bind to its fields by name, so expression order is free — but every expression must therefore be a bare col or carry an .alias("field_name"). Right-side fields made nullable by a left join must be optional in R.

Expressions

col("name") and lit(value) are the Expr leaves. Comparisons are methods — .eq, .ne, .lt, .le, .gt, .ge — because Atoll’s comparison operators return bool and so cannot build an expression tree. Combine predicates with &, |, and !:

schema travel

model Booking {
    @id
    id: int
    nights: int
    status: string
}

fn confirmed_long_stays(): []Booking ! QueryError {
    frame := FROM Booking AS FRAME?
    return frame.df()
        .filter(
            col("nights").ge(lit(3))
                & col("status").eq(lit("confirmed"))
        )
        .collect()?
}

fn main(): void {
    match confirmed_long_stays() {
        Ok(rows) => println("${rows.len()} confirmed long stays")
        Err(e) => println("plan failed")
    }
}

Expressions also support arithmetic, aliases, aggregates such as .sum() and .count(), scalar-list membership, and literal substring predicates.

Frame queries

A local Frame[Row] or Df[Row] can itself be a FROM source. This lowers to dataframe operations, not database SQL:

schema travel

model Booking {
    @id
    id: int
    nights: int
    status: string
}

fn frame_of(): Frame[Booking] ! QueryError {
    return FROM Booking AS FRAME?
}

// Default terminal: a guest list.
fn long_stays(minimum: int): []Booking ! QueryError {
    frame := frame_of()?
    return FROM frame
        WHERE nights > minimum
        ORDER BY nights DESC?
}

// `ONE FROM` over a frame: an optional row.
fn any_long_stay(minimum: int): Booking? ! QueryError {
    frame := frame_of()?
    return ONE FROM frame WHERE nights > minimum?
}

// `AS FRAME` again: a narrower host-resident frame.
fn narrowed(minimum: int): int ! QueryError {
    frame := frame_of()?
    smaller := FROM frame WHERE nights > minimum AS FRAME?
    return smaller.count()
}

// `AS DF`: the lazy plan, with no `Result` wrapper.
fn as_plan(minimum: int): []Booking ! QueryError {
    frame := frame_of()?
    plan := FROM frame WHERE nights > minimum AS DF
    return plan.collect()?
}

fn main(): void {
    match long_stays(3) { Ok(rows) => println("${rows.len()} long stays") Err(e) => println("failed") }
    match any_long_stay(3) {
        Ok(Some(row)) => println("found booking ${row.id}")
        Ok(None) => println("none")
        Err(e) => println("failed")
    }
    match narrowed(3) { Ok(n) => println("${n} rows kept") Err(e) => println("failed") }
    match as_plan(3) { Ok(rows) => println("${rows.len()} from the plan") Err(e) => println("failed") }
}

The terminals mirror the table-source ones:

Terminal Type
default Result[[]Row, QueryError]
ONE FROM Result[Row?, QueryError]
AS FRAME Result[Frame[Row], QueryError]
AS DF Df[Row] — no Result, nothing has executed

AS DF is available only for frame-local queries; on a table query it is rejected.

The supported frame-query subset covers predicates, projections, grouping, having, ordering, paging, and same-named-key joins. CTEs, windows, set operations, runtime sort directions, conditional clauses, right and cross joins, and renamed join keys all diagnose.

Files

read_parquet[T](path) and read_csv[T](path) return Result[Df[T], QueryError] — note the Result. The ? is mandatory before any Df method, or you get ATOLL2003: no method collect on type Result[Df[Row], QueryError]:

struct Row {
    id: int
    total: float
}

fn from_parquet(path: string): []Row ! QueryError {
    df := read_parquet[Row](path)?
    return df.collect()?
}

fn big_rows_from_csv(path: string, floor: float): []Row ! QueryError {
    df := read_csv[Row](path)?
    return df
        .filter(col("total").ge(lit(floor)))
        .sort("total", true)
        .collect()?
}

fn park_in_host(path: string): int ! QueryError {
    df := read_parquet[Row](path)?
    frame := df.head(1000).to_frame()?
    return frame.count()
}

fn main(): void {
    match from_parquet("orders.parquet") { Ok(rows) => println("${rows.len()}") Err(e) => println("read failed") }
    match big_rows_from_csv("orders.csv", 100.0) { Ok(rows) => println("${rows.len()}") Err(e) => println("read failed") }
    match park_in_host("orders.parquet") { Ok(n) => println("${n} parked") Err(e) => println("read failed") }
}

The host validates the file schema against T: extra columns are projected away, while a missing or incompatible required column returns QueryError. CSV uses its header plus the declared T rather than inferring a shape that could change under you.

These scans are lazy — opening metadata validates the schema, and a terminal executes the scan. File ingestion needs no configured datasource, which makes it the easiest way to try the Df surface.

Writing frames to files, raw dataframe SQL, regular-expression predicates, cloud or glob scans, and window operations over frames are not part of the current surface.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close