SCUA

Manual

Data tables (frames)

A frame is a small data table — named columns of values you can total, sort, and group, the way you would a spreadsheet or a database query. It's built for the everyday question "given this data, what's the answer?" — and because SCUA has an exact decimal number, a column of money totals exactly, where a float-first tool would drift.

#Building a frame

Pass a table of { column-name = array } — every column the same length:

let sales = frame({
  region  = ["EU", "US", "EU", "US"],
  amount  = [10.50d, 20.00d, 5.25d, 7.75d],   -- exact decimal money
  qty     = [3, 1, 4, 2],
})

A frame prints as an aligned table, so a tool can show someone the answer directly:

$ scua sales.scua
region  amount  qty
EU      10.50   3
US      20.00   1
EU      5.25    4
US      7.75    2
(4 rows × 3 cols)

For a crisper rendering you can print or copy elsewhere — a report, a chat message, a code comment — to_table() returns the frame as a box-drawing table string (the way DuckDB prints), with numeric columns right-aligned:

print(sales.to_table())
┌────────┬────────┬─────┐
│ region │ amount │ qty │
├────────┼────────┼─────┤
│ EU     │  10.50 │   3 │
│ US     │  20.00 │   1 │
│ EU     │   5.25 │   4 │
│ US     │   7.75 │   2 │
└────────┴────────┴─────┘
(4 rows × 3 cols)

Unlike the default print (which caps a wide preview at 20 rows), to_table() renders every row — it's an explicit export helper.

#Querying it

Operations read on the frame and chain, left to right:

print(sales.rows())                 -- 4
print(sales.column_names())         -- [region, amount, qty]
print(sales.total("amount"))        -- 43.50  — EXACT (a decimal column stays exact)
print(sales.total("qty"))           -- 10     — an int column totals to an int
print(sales.mean("qty"))            -- 2.5

-- biggest sales: sort descending, keep the first rows and a couple of columns
print(sales.sort_by("amount", true).head(2).pick(["region", "amount"]))

-- the everyday analytical question: group by one column, sum another (exact for money)
print(sales.group_sum("region", "amount"))   -- EU 15.75, US 27.75
Operation Result
f.rows() the number of rows
f.column_names() the column names, in order
f.column(name) one column as an array
f.head(n) a new frame with the first n rows
f.take(indices) a new frame with the rows at the given indices, in that order
f.pick(names) a new frame with only the named columns, in that order
f.sort_by(name) / f.sort_by(name, true) rows ordered by a column (ascending / descending)
f.total(name) the sum of a column — exact for a decimal column; int→int, float→float
f.mean(name) the average of a column (as a float)
f.filter(predicate) keep the rows where the predicate holds — column names are written bare
f.search(needle[, opts]) rows whose string cells match needle (fixed-string by default; see column search)
f.with(name, expr) a new frame with column name computed per row from expr (bare column names)
f.with_column(name, values) a new frame with column name set to a pre-computed values array (the lower-level form of with)
f.group_sum(key, value) group by the key column and sum the value column per group → a 2-column frame
f.join(other, key[, kind]) join two frames on a shared key column — "inner" (default) or "left"
f.pivot(index, columns, values) a pivot table: a row per index value, a column per columns value, each cell the sum of values
f.unpivot(ids, values[, name_col, value_col]) melt: keep ids, stack the values columns into a long name/value pair
f.row(i) one row (0-based) as a table keyed by column name
f.to_table() the frame rendered as a box-drawing table string (for printing or copying elsewhere)

Every operation returns a new frame (or a value) — nothing is mutated in place, and there's no hidden row index to trip over. Empty cells (nil) are skipped by total/mean.

#Column names are checked

When you build a frame from a frame({ … }) literal, its column names are known at compile time, so a mistyped column is caught before the program runs — with a suggestion:

let sales = frame({ region = ["EU", "US"], amount = [10.50d, 20.00d] })
sales.total("amont")          -- error: frame has no column 'amont' — did you mean 'amount'?
sales.filter(regon == "EU")   -- error: frame has no column 'regon' — did you mean 'region'?

The column types are checked too: a reducer that sums or averages — total, mean, group_sum's value column, pivot's values column — must name a number column, so summing a text column is caught up front rather than faulting mid-run:

sales.total("region")   -- error: can't `total` column 'region' — it holds string values, not numbers

The schema follows the chain — sales.filter(…).total("amont") is checked too — and pick, with, and group_sum carry the right columns forward. Frames whose columns aren't known up front (a frame you took as a function argument, or one from csv, join, pivot, or an embedding host) stay flexible: their column names and types are checked at runtime instead, so nothing in your way when the shape is genuinely dynamic.

#Filtering and computed columns

In filter and with, column names are written bareamount, region — and read as columns of the row; an outer variable (like a threshold you defined) keeps its normal meaning:

let cutoff = 100.00d
sales
  .filter(region == "EU" and amount > cutoff)   -- amount/region are columns; cutoff is the local
  .with("net", amount - fee)                     -- a new column computed per row

For logic the bare form can't express, pass a function of the row instead (the escape hatch):

sales.filter(fn(row) return row.amount > row.fee * 2.00d end)

#Search a text column

Filter free-text columns with the same engine as regex / fs.grep:

import regex

let tickets = frame({
  id = [1, 2, 3],
  note = ["refund request", "shipped OK", "CHARGEBACK dispute"],
})

-- Fixed-string: `col.contains(lit)` (substring) inside filter.
print(tickets.filter(note.contains("ship")))

-- Regex: compile once, then `col.matches(re)`.
match regex.compile("(?i)refund|chargeback")
  Ok(re) -> print(tickets.filter(note.matches(re)))
  Error(why) -> print(why)
end

-- Multi-column shortcut: keep rows where any (or named) string cell matches.
print(tickets.search("chargeback", { ignore_case = true, columns = ["note"] }))
print(tickets.search("ship|other", { regex = true }))   -- string pattern

search options: columns (array of names; default all string cells), ignore_case, regex (string needle is a pattern; a compiled re is always regex). Non-string cells are skipped unless you name the column explicitly, in which case a non-string value faults.

#Loading from CSV

csv(text) parses CSV text straight into a frame — the first line names the columns, each later line is a row. Cells are typed per value: a whole number becomes an int, a decimal-point number an exact decimal (so a money column stays exact), anything else a string, and an empty cell is nil. Quoted fields with embedded commas are handled.

let f = csv("city,revenue
Paris,1200.50
Tokyo,980.00
Paris,300.25")

print(f.group_sum("city", "revenue"))   -- Paris 1500.75, Tokyo 980.00 — exact

(A quoted field may not span multiple lines in this version.)

#Combining and reshaping

join combines two frames on a shared key column — "inner" (only matching rows, the default) or "left" (every left row, with the right columns nil where there's no match). A right column whose name clashes with a left one is suffixed _right:

let tax = frame({ region = ["EU", "US"], rate = [0.20d, 0.08d] })
sales.join(tax, "region")            -- adds a `rate` column to each sales row

pivot makes a pivot table — one row per distinct index value, one column per distinct columns value, each cell the sum of the values column for that pair (exact for a decimal column; an empty cell is nil):

sales.pivot("region", "product", "amount")   -- region down the side, product across the top

unpivot is the inverse — melt a wide table into a long one. Keep the ids columns, and stack the named values columns into two columns: a name column saying which column each value came from, and a value column holding it. The rows come out variable-major (all rows of the first value column, then the second, …); the two new column names default to "name"/"value":

let wide = frame({ region = ["EU", "US"], pen = [10.50d, 20.00d], ink = [5.25d, 7.75d] })
wide.unpivot(["region"], ["pen", "ink"], "product", "amount")
-- region | product | amount
-- EU     | pen     | 10.50
-- US     | pen     | 20.00
-- EU     | ink     | 5.25
-- US     | ink     | 7.75

#Why exact decimal columns matter

Totalling money is the thing data tools get asked most, and it's where float-based tools quietly mislead: 0.1 + 0.2 is not 0.3 in floating point. A decimal column (write the literal with a d suffix — 10.50d) sums and groups exactly, so a column of prices or invoice lines adds up to the cent.

See examples/frames.scua for a runnable tour.