Tables

Tables are Parsley's first-class type for working with structured, rectangular data. Think of a Table as a spreadsheet or database result setβ€”rows of data where each row has the same columns. Tables provide powerful SQL-like operations for filtering, sorting, selecting, and aggregating data, all with a clean method-chaining syntax.

let sales = @table [
    {product: "Widget", region: "North", amount: $1200},
    {product: "Gadget", region: "South", amount: $800},
    {product: "Widget", region: "South", amount: $1500},
    {product: "Gadget", region: "North", amount: $950}
]

sales
    .where(fn(r) { r.region == "South" })
    .orderBy("amount", "desc")
    .select(["product", "amount"])

Result:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ product β”‚ amount β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Widget  β”‚ $1,500 β”‚
β”‚ Gadget  β”‚ $800   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why Tables?

Parsley is designed for web and data. While arrays of dictionaries work for simple cases, Tables provide:

Feature Array of Dicts Table
Column consistency Not enforced Guaranteed
SQL-like operations Manual loops Built-in methods
Export formats DIY .toCSV(), .toHTML(), .toMarkdown()
Aggregations Manual .sum(), .avg(), .min(), .max()
Schema validation None Optional @schema integration
Memory efficiency N/A Copy-on-chain optimization

Tables are ideal for:


Creating Tables

The @table Literal

The most direct way to create a Table is with the @table literal syntax:

let users = @table [
    {name: "Alice", age: 30, active: true},
    {name: "Bob", age: 25, active: false},
    {name: "Carol", age: 35, active: true}
]

The @table literal provides parse-time validation:

// This is a PARSE ERROR β€” "age" missing from second row:
let bad = @table [
    {name: "Alice", age: 30},
    {name: "Bob"}  // Error: missing column 'age'
]

The table() Constructor

For dynamic data, use the table() constructor:

let data = [{x: 1}, {x: 2}, {x: 3}]
let t = table(data)

The constructor validates at runtime:

table([{a: 1}, {b: 2}])  // Error: column mismatch
table("not an array")    // Error: requires array
table([1, 2, 3])         // Error: elements must be dictionaries

Empty Tables

Both syntaxes support empty tables:

let empty1 = @table []
let empty2 = table([])

empty1.length    // 0
empty1.columns   // []

Tables from External Sources

CSV Files

The CSV() function and .parseCSV() method return Tables directly:

// From a file
let sales <== CSV("sales.csv")

// From a string
let data = "name,age\nAlice,30\nBob,25".parseCSV()

CSV columns come from the header row. Values are automatically parsed as numbers, booleans, or strings:

let csv = "product,price,in_stock\nWidget,9.99,true\nGadget,19.99,false"
let products = csv.parseCSV()

products[0].price     // 9.99 (number, not string)
products[0].in_stock  // true (boolean, not string)

Database Queries

Database table bindings return Tables with full schema information:

// Basil database binding
let users = Users.all()
let active = Users.where(fn(u) { u.active })

// Raw SQL also returns Table
let results <=?=> "SELECT * FROM orders WHERE total > 100"

Database-backed Tables carry schema metadata:

users.schema  // Contains column types from database

JSON APIs

JSON APIs return arrays by default. Convert to Table explicitly:

let response <=/= fetch("https://api.example.com/users")
let users = table(response.json)

Tables with Schemas

Typed Tables with @table(Schema)

Combine @table with @schema for validated, typed tables with defaults:

@schema Product {
    sku: string
    name: string
    price: money
    in_stock: bool = true
    discount: number = 0
}

// Schema defaults are applied when fields are missing from ALL rows
let products = @table(Product) [
    {sku: "A001", name: "Widget", price: $9.99},
    {sku: "A002", name: "Gadget", price: $19.99}
]

products[0].in_stock  // true (default applied)
products[0].discount  // 0 (default applied)
products[1].in_stock  // true (default applied)

Note: All rows in a @table must have the same columns. To override a default, include the field in every row:

let products = @table(Product) [
    {sku: "A001", name: "Widget", price: $9.99, in_stock: true, discount: 0},
    {sku: "A002", name: "Gadget", price: $19.99, in_stock: false, discount: 10}
]

Nullable Fields

Use ? suffix for optional fields. Nullable fields can hold null values:

@schema Contact {
    name: string
    email: email
    phone: phone?  // nullable β€” can hold null
}

let contacts = @table(Contact) [
    {name: "Alice", email: "alice@example.com", phone: "555-1234"},
    {name: "Bob", email: "bob@example.com", phone: null}
]

contacts[0].phone  // "555-1234"
contacts[1].phone  // null

Note: All rows must include all columns. Use null explicitly for missing nullable values.

Required Field Validation

Non-nullable fields without defaults must be provided:

@schema User {
    id: ulid
    email: email
}

// This is an ERROR β€” missing required "email":
let bad = @table(User) [
    {id: "01H..."}
]
// Error: Table row 1: missing required field 'email'

Attributes

columns

Returns an array of column names:

let t = @table [{name: "Alice", age: 30}]
t.columns

Result: ["name", "age"]

length

Returns the number of rows:

let t = @table [{x: 1}, {x: 2}, {x: 3}]
t.length

Result: 3

rows

Returns all rows as an array of dictionaries:

let t = @table [{a: 1}, {a: 2}]
t.rows

Result: [{a: 1}, {a: 2}]

schema

Returns the attached schema, or null if none:

@schema Point { x: int, y: int }
let points = @table(Point) [{x: 1, y: 2}]
points.schema  // Point schema object

Schema Checking with is

Use the is and is not operators to check whether a Table is bound to a specific schema:

@schema User { name: string }
@schema Product { sku: string }

let users = @table(User) [{name: "Alice"}]
let products = @table(Product) [{sku: "A001"}]
let untyped = @table [{name: "Bob"}]

users is User                   // true
users is Product                // false
products is Product             // true
untyped is User                 // false (no schema)

This is useful when you need to dispatch based on table type or validate that data has the expected schema.


Indexing and Iteration

Row Access

Access rows by index (zero-based):

let t = @table [{name: "Alice"}, {name: "Bob"}, {name: "Carol"}]

t[0]   // {name: "Alice"}
t[1]   // {name: "Bob"}
t[-1]  // {name: "Carol"} (last row)

Iteration

Tables are iterableβ€”use for to loop over rows:

let users = @table [
    {name: "Alice", role: "admin"},
    {name: "Bob", role: "user"}
]

for (user in users) {
    <p>"{user.name} is a {user.role}"</p>
}

Result:

<p>Alice is a admin</p>
<p>Bob is a user</p>

SQL-Like Query Methods

Tables provide a fluent, chainable API inspired by SQL. Chain methods together to build expressive queries.

where(fn)

Filter rows using a predicate function:

let orders = @table [
    {id: 1, customer: "Alice", total: $150},
    {id: 2, customer: "Bob", total: $75},
    {id: 3, customer: "Alice", total: $200}
]

orders.where(fn(r) { r.total > $100 })

Result:

β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id β”‚ customer β”‚ total β”‚
β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1  β”‚ Alice    β”‚ $150  β”‚
β”‚ 3  β”‚ Alice    β”‚ $200  β”‚
β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

Combine conditions with && and ||:

orders.where(fn(r) { r.customer == "Alice" && r.total > $175 })

orderBy(column) / orderBy(column, direction)

Sort rows by a column. Default is ascending:

let products = @table [
    {name: "Banana", price: $1.50},
    {name: "Apple", price: $2.00},
    {name: "Cherry", price: $3.50}
]

products.orderBy("price")

Result:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ name   β”‚ price β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Banana β”‚ $1.50 β”‚
β”‚ Apple  β”‚ $2.00 β”‚
β”‚ Cherry β”‚ $3.50 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

columnProps(column)

Returns a dictionary of display metadata for a column. This is used by the DataTable component to derive column display properties from schemas.

Signature: table.columnProps(column) β†’ dictionary

The returned dictionary contains:

Key Description
name Column identifier (string)
label From schema title or titlecased column name (e.g. "created_at" β†’ "Created At")
type Original schema type (only present if schema exists)
align "left", "right", or "center" (derived from type)
format Format hint (only present if applicable): "currency", "date", "datetime", "duration", "unit", "boolean"

Alignment rules:

Without a schema, returns minimal props with name, label, and align="left".

Without schema:

let t = table([{created_at: "2025-01-01"}])
t.columnProps("created_at")

Result: {name: "created_at", label: "Created At", align: "left"}

With schema:

@schema Order {
    total: money | {title: "Order Total"}
    active: boolean
}
let orders = table([{total: Β£100.00, active: true}]).as(Order)

orders.columnProps("total")
// β†’ {name: "total", label: "Order Total", type: "money", align: "right", format: "currency"}

orders.columnProps("active")
// β†’ {name: "active", label: "Active", type: "boolean", align: "center", format: "boolean"}

Tables

Tables are Parsley's rectangular data type: each row is a dictionary and every row has the same columns. They power CSV handling, database results, reporting, and any place you would normally reach for SQL-style transforms.

let sales = @table [
    {product: "Widget", region: "North", amount: $1200},
    {product: "Gadget", region: "South", amount: $800},
    {product: "Widget", region: "South", amount: $1500},
    {product: "Gadget", region: "North", amount: $950}
]

sales
    .where(fn(r) { r.region == "South" })
    .orderBy("amount", "desc")
    .select(["product", "amount"])

Ways to Create a Table

Empty tables are allowed in all forms: @table [] and table([]) both produce length = 0 and columns = [].


Shape, Access, and Properties


Copy-on-Chain

Chaining is efficient: it only makes one copy.

The first mutating-style method in a chain makes one copy; later calls in the same chain reuse it. The chain ends when you assign, pass as an argument, iterate, or access a property. Use .copy() for an explicit non-chain copy.


Core Query Methods

Example:

let top = sales
    .where(fn(r) { r.region == "South" })
    .orderBy("amount", "desc")
    .limit(2)
    .select(["product", "amount"])

Aggregations


Column and Group Helpers

Example with aggregation:

let totals = sales.groupBy(["region", "product"], fn(rows) {
    let sum = 0
    for (r in rows) { sum = sum + r.amount }
    {total: sum}
})

Functional Helpers


Building and Editing Rows/Columns

All of these return new tables (chain copies when part of a chain).


Validation and Typed Tables

Example:

@schema User { id: ulid, email: email }

let raw = table([
    {id: "01H7...", email: "alice@example.com"},
    {id: "01H8...", email: "not-an-email"}
]).as(User)

let checked = raw.validate()
checked.isValid()    // false
checked.errors()     // [{row: 1, field: "email", ...}]
checked.validRows()  // only the valid row

Export and Presentation


Interop Notes

See Also