Skip to main content

Query - reified queries

A query chain in Xi does not execute where it is written. The compiler turns the whole chain into a plan - a typed tree value - and hands it to a provider, which decides what the plan means: run it in memory, translate it to SQL, forward it over the wire, or reject what it can't honor. One query syntax, any backend.

import "std/query.xi"

type User = { id: Integer, name: String, age: Integer }

let adults = query.from<User>("users")
.filter { it.age >= 18 and it.name.startsWith("A") }
.sortedBy { it.name }
.take(10)
.collect(db) // -> List<User>, no casts

Nothing before .collect touches data. The element type threads through the chain - query.from<User> starts it, .map { it.age } narrows it to Integer, and collect decodes the provider's rows back into the right type automatically.

Stages

StageEffectElement after
query.from<T>("src")root: the source to queryT
.filter { pred }keep rows where pred holdsunchanged
.map { expr }project each rowthe expression's type
.sortedBy { key } / .sortedByDescending { key }order rowsunchanged
.take(n) / .drop(n)limit / skipunchanged
.concat(other)append another query's rows (same element type)unchanged
.join(other, { lk }, { rk })pair rows whose keys agreea pair - it.first / it.second
.groupBy { key }group rows by keya group - it.key + aggregates
.collect(provider)run the planList<element>
.planthe reified plan value itselfQueryPlan

from defaults its source to the type name: query.from<User>() queries "User" - providers map source names however they like.

Querying in-memory collections

A plain List<T> (or T[]) roots a query too - asQuery() snapshots its rows into the plan, and .toList() runs the chain locally, no provider needed:

let employees = empty List<User>

let picked = employees.asQuery()
.filter { it.active }
.take(5)
.toList() // -> List<User>

The eager list API already covers filter/map/take; what asQuery adds is the query-only stages over ordinary collections - equi-join between two lists, groupBy with aggregates into records - with the same syntax used against a provider:

let spend = orders.asQuery()
.groupBy { it.userId }
.map { Spend { orders: it.count(), total: it.sum { x => x.amount } } }
.toList()

let views = people.asQuery()
.join(orders.asQuery(), { it.id }, { it.userId })
.map { View { who: it.first.name, spent: it.second.amount } }
.toList()

asQuery copies the rows at that point (a snapshot); .toList() is only for list-rooted queries (a source-rooted query needs .collect(provider)), and a list-rooted plan can't render to SQL - both mistakes are clear errors.

What a lambda may contain

Query lambdas are reified, not executed, so they carry a closed set of shapes the plan can express:

  • the lambda parameter and its fields: it.age, it.addr.city
  • literals, and or not, comparisons, + - * / %, in, matches
  • string methods: contains, startsWith, endsWith, lowercase, uppercase, length
  • captured locals - evaluated immediately and embedded as bound values:
let minAge = 18
.filter { it.age >= minAge } // the VALUE 18 is in the plan, not the name
  • record projections - build a shape per row:
.map { UserView { who: it.name, tag: it.name.lowercase() } }

Anything else is a compile-time error at the source line - a typo'd field (it.nmae) reports type 'User' has no field 'nmae', an unknown method names the supported set.

join and groupBy

// pair users with their orders; address sides as first/second
let views = query.from<User>("users")
.join(query.from<Order>("orders"), { it.id }, { it.userId })
.filter { it.second.amount > 8 }
.map { UserView { who: it.first.name, spent: it.second.amount } }
.collect(db)

// aggregate per key: it.key plus count/sum/avg/min/max over the group
let stats = query.from<Order>("orders")
.groupBy { it.userId }
.map { Spend { user: it.key, orders: it.count(), total: it.sum { x => x.amount } } }
.collect(db)

Joins are equi-joins (two key lambdas), which keeps them cheap in memory and translatable everywhere. Joined and grouped rows must be projected with .map { ... } before .collect.

Providers

A provider is any class implementing one interface:

interface QueryProvider {
producer run(plan: QueryPlan) -> Json // result rows as a Json array
}

std/query.xi ships MemorySource - the in-memory reference interpreter every other provider must agree with. Load rows through its RowStore view; bind both views to it as singleton and they share one instance:

module App {
bind QueryProvider -> MemorySource as singleton
bind RowStore -> MemorySource as singleton
}

App.resolve(RowStore).load("users", json.parse(rowsText))
let out = query.from<User>("users").filter { it.age > 21 }.collect(App.resolve(QueryProvider))

Because the provider is an interface, tests swap a database for MemorySource with a one-line bind - same queries, no infrastructure.

A real backend is the same one method. examples/query/sqlite_query_demo.xi implements QueryProvider over libsqlite3: its run renders the plan with sqlRender + SqliteDialect, executes the statement with bound parameters, and returns the rows - so a chain .collect(sqlite) comes back as typed values straight from the database.

producer run(plan: QueryPlan) -> Json {
let stmt = (sqlRender(plan, SqliteDialect {} as SqlDialect)).value
// prepare stmt.text, bind stmt.params, step, return rows as Json
}

The plan is data

QueryPlan / QueryStage / QueryExpr are ordinary sum types (see std/query.xi) - walk them with match:

mapper describe(e: QueryExpr) -> String {
match e {
QField f -> { return f.path }
QBin b -> { return "(" + describe(b.left) + " " + b.op + " " + describe(b.right) + ")" }
QParam v -> { return json.stringify(v.value) }
...
}
}

And they serialize - plan as Json / json as QueryPlan round-trip - so a plan can be logged, cache-keyed, or shipped to a remote query service.

Node kinds: QLit, QField, QParam, QBin, QUn, QCall, QAgg, QRecord. Stage kinds: QFilter, QProject, QSortBy, QTake, QDrop, QConcat, QJoin, QGroupBy. Captured values arrive as QParam nodes - already evaluated, ready to bind.

SQL rendering (std/sql.xi)

sqlRender(plan, dialect) folds a plan into one SELECT with bound parameters (values are never spliced into the text):

import "std/sql.xi"

let st = (sqlRender(q.plan, SqliteDialect {} as SqlDialect))?
// st.text SELECT * FROM "users" WHERE ("age" >= ?) ORDER BY "name" LIMIT 10
// st.params [18]

Dialects are an interface - SqliteDialect, PostgresDialect ($1 placeholders), MysqlDialect are bundled; implement SqlDialect to add your own. A dialect that can't translate a reified method call fails the render with the method named, so untranslatable queries are a clear error, not a silent wrong answer.

HookDecides
name()the dialect's short name - "sqlite", "postgres", "mysql"
placeholder(n)? vs $n
quoteIdent(name)"col" vs `col`
callSql(method, recv, args)how string methods translate ("" = can't)
regexpExpr(recv, pattern)the matches operator
limitSql(...)LIMIT/OFFSET spelling

Scope notes

  • A plan value is built once per chain; branch by building separate chains.
  • Joining an already joined or grouped query isn't supported - project it first.
  • MemorySource's stage-by-stage interpretation is the reference semantics; a translating provider may fold stages into one statement only where the meaning is preserved (the plan records stage order verbatim).