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.