oql
Readable, declarative query syntax for Rust iterators. Compiles down to a plain iterator chain, so the macro disappears in release builds.
use oql;
let numbers = vec!;
let squared_evens: = oql!
.collect;
assert_eq!;
Why
Rust's iterator chains are powerful and fast, but once you stack several
.filter().map().flat_map() calls, the intent gets buried under
mechanics. oql! takes the same pipeline and writes it top-to-bottom
like a paragraph. The macro expands to a straightforward iterator chain
with no runtime, no reflection, and no dependencies in the surface
crate.
Clauses
| Clause | Meaning |
|---|---|
from x in source |
Starts the query; expands to source.into_iter(), so any IntoIterator is accepted |
let name = expr |
Adds a per-element binding; expr is re-evaluated for each element and can reference the range variable and prior bindings |
where cond |
Filters elements |
orderby key |
Sorts ascending |
orderby key desc |
Sorts descending |
join y in src on a == b |
Inner equality join (hash-join under the hood) |
join y in src on a == b into g |
Group-join: g is a Vec<Y> of matches (empty if none) |
group elem by key into g |
Group elements by key; g.key and g.items downstream |
select expr |
Projects to the output type |
from and select are mandatory. Everything else is optional and may
appear multiple times in any order. Clause order equals execution
order. This is deliberate, so the pipeline reads linearly and behaves
exactly like a hand-written iterator chain would.
group by is a pipeline barrier like orderby: every upstream element
must be seen before the first group can be yielded, and after the
clause the environment collapses to just the group binding. Later
clauses see whole groups, not individual elements. g.items is a plain
Vec<T>, so every Iterator method (sum, count, max, fold, and
so on) is directly available.
The macro returns an Iterator. The caller decides whether to
.collect(), .sum(), .take(10).collect(), .for_each(...), and so
on. This is why there is no limit, offset, count, or distinct
clause: Rust's Iterator trait already provides every terminal and
adapter operation you might want. The same applies to union,
intersect, and except. These belong in your follow-up code (for
example via HashSet::union).
Examples
A filter plus projection:
use oql;
let xs = vec!;
let out: = oql!
.collect;
assert_eq!;
Intermediate bindings:
use oql;
let orders: = /* ... */;
let high_value: = oql!
.collect;
Inner join:
use oql;
let orders: = /* ... */;
let customers: = /* ... */;
let german_sales: = oql!
.collect;
Composite sort keys:
use oql;
let items = vec!;
let out: = oql!
.collect;
assert_eq!;
Group by with aggregation:
use oql;
let orders: = /* ... */;
// Revenue per customer.
let per_customer: = oql!
.collect;
Group-join:
use oql;
let customers: = /* ... */;
let orders: = /* ... */;
// Each customer with their full list of orders (possibly empty).
let report: = oql!
.collect;
How the expansion works
For every oql! invocation, the macro walks the clauses in order and
emits a plain iterator chain. Each clause maps to a familiar adapter:
wherebecomes.filter_map(|env| if cond { Some(env) } else { None }). The macro usesfilter_maprather thanfilterbecause new bindings introduced byletmay change the shape of the environment tuple between steps.let name = expraddsnameto the environment tuple so it is available in every subsequent clause (until it is dropped by liveness analysis or agroup byresets the environment). For efficiency the computation is fused into the following step's closure instead of getting its own adapter, and multiple consecutivelets are baked into a single.map()closure together, not one adapter per binding.orderby keycollects into aVec<(key, env)>, sorts in place, then yields back as an iterator. Multiple consecutiveorderbyclauses merge into one sort with a composite key tuple. The key is cloned once per element (via(&expr).clone()).Copytypes copy for free,String-like types clone exactly once, which is the minimum any stable sort needs.join y in src on a == bbuilds aHashMap<K, Vec<T>>from the inner source in a preamble, then a.flat_map(...)on the outer iterator probes it.O(n + m)instead of the naiveO(n · m)nested loop.selectbecomes.map(|env| projection).
Between steps, the macro propagates an environment tuple of every
binding that will still be needed. A backwards live-variable analysis
(see oql-macro/src/liveness.rs) prunes bindings that no later clause
reads, so the tuple stays as narrow as the data actually demands. Dead
let bindings keep their value expression (for side-effect parity) but
don't enter the outgoing tuple.
You can inspect the expansion yourself:
The simple_compare example contains a handwritten and an oql!
version of the same tiny pipeline side by side.
Performance
The macro is a syntax rewriter, not a runtime. It inherits every iterator-chain optimisation LLVM and the Rust optimizer already apply: closures and environment tuples are inlined away in release, and the compiled code is measured against a handwritten equivalent.
For simple queries (from / where / let / select), the generated code
is indistinguishable from a hand-written .filter().map() chain, within
measurement noise.
For a join-and-sort query with 10 000 orders × 1 000 customers
(benches/throughput.rs, cargo bench):
| Variant | Time |
|---|---|
| Handwritten: hash-join + filter + collect + sort | ≈ 755 µs |
oql! expansion (equivalent semantics) |
≈ 800 µs |
That is roughly 1.07× the cost of the handwritten pipeline (median
of 10 runs, stddev 0.02). Most of the remaining gap comes from the
macro's slightly wider intermediate tuple during the sort step. The
handwritten version inlines the final projection into the flat_map
body, which lets it allocate only (String, u64) pairs by the time
the sort runs. The macro keeps the projection in its own .map()
stage so .take(n) after the query short-circuits correctly through
the sort, which means the sort payload is (Customer, u64) instead of
(String, u64).
Where the handwritten version fuses a where after a join into the
same flat_map body, so does the macro. The safety check in
liveness::bare_idents makes sure no captures would silently be
pulled into the move closure as a side effect.
Measurements vary with system load. On a warm VM the ratio is stable
between 1.03× and 1.10×, and absolute numbers will differ on your
machine. Run scripts/multi_bench.sh 10 to collect your own data.