# Supported query shapes

The honest matrix of what builds, pushes, and materializes today — written in the Rust rindle::table builder. The explicit list of what does not work yet.

This page is the contract: which query shapes Rindle can lower into an
incrementally-maintained pipeline, and what each shape does at the three stages
of the engine.

> **Writing TypeScript?** Every shape here is in the
> [TypeScript query builder reference](/docs/supported-queries-ts) too — same
> verdicts, the `@rindle/client` builder.

- **fetch** — hydrate the pipeline and materialize the initial result.
- **push** — apply incremental source changes and emit the downstream change stream.
- **view** — the change reaches the materialized result and updates it in place.

Legend: ✅ supported · ⚠️ partial (see note) · ❌ unsupported. Every ✅ row is
backed by a passing test in the engine repo.

## How you express a query

A query is written in **Deltic** — Rindle's query language — using the fluent
builder (`rindle::table`), which produces an
`Ast`. You hand that `Ast` to the live-query wrapper, subscribe, and receive the
incremental change stream — there is no SQL string and no separate CLI; the
query *is* a Rust value.

```rust
use rindle_replica::{ChangeEvent, Db, QueryId, Update};

fn main() -> Result<(), rindle_replica::ReplicaError> {
    let db = Db::open("app.db")?;
    db.register_table("issues")?;

    // Build a query: open issues, ordered, capped. `rindle::table` returns a
    // fluent `Query`; `.build()` consumes it and yields the `Ast`.
    let ast = rindle::table("issues")
        .r#where("open", true)          // `field = value`
        .where_op("priority", ">", 3)   // explicit operator
        .order_by("created_at", "desc")
        .limit(50)
        .build();

    let query = db.query(QueryId(1), ast)?;

    // Subscribe: the callback fires once with `Hydrated` (the initial set, all
    // `Add`s), then with `Changed` after every committed write that affects it.
    query.subscribe(|update: &Update| match update {
        Update::Hydrated { changes, .. } => {
            for ch in changes {
                if let ChangeEvent::Add(node) = ch {
                    println!("initial row: {:?}", node.row);
                }
            }
        }
        Update::Changed { changes, .. } => {
            for ch in changes {
                println!("delta: {ch:?}");
            }
        }
    });

    // Write through the single controlled writer. `commit` derives the delta and
    // delivers `Update::Changed` to the subscriber above before returning.
    let mut tx = db.write()?;
    tx.exec_batch(
        "INSERT INTO issues (id, title, open, priority, created_at)
         VALUES (42, 'ship it', 1, 7, 1700000000)",
    )?;
    tx.commit()?;

    Ok(())
}
```

`ChangeEvent` is the engine's `rindle::CaughtChange`, re-exported by the replica.
Its four shapes are the entire incremental vocabulary:

```rust
pub enum CaughtChange {
    Add(CaughtNode),                                  // a row entered the result
    Remove(CaughtNode),                              // a row left the result
    Edit { old: OwnedRow, row: OwnedRow },           // a row's columns changed in place
    Child { row: OwnedRow, rel: RelId, change: Box<CaughtChange> }, // a related row changed
}
```

See [the change model](/docs/change-model) for the full delta semantics and
[replica and views](/docs/replica-and-views) for the one-writer / write-then-abort
mechanism that produces them.

## Correlated relationships and EXISTS

Relationships are correlated subqueries. Inside a `sub` / `sub_as` /
`where_exists` closure, the closure receives the parent row; `row.col("…")`
references a **parent** column, and using it as a `where` value defines the
correlation (it is not a filter):

```rust
// issues, each carrying its comments (a materialized relationship)
let with_comments = rindle::table("issues")
    .sub_as("comments", |row| {
        rindle::table("comments").r#where("issue_id", row.col("id"))
    })
    .build();

// only issues that have at least one comment (an EXISTS filter)
let commented = rindle::table("issues")
    .where_exists(|row| {
        rindle::table("comments").r#where("issue_id", row.col("id"))
    })
    .build();

// the negation
let uncommented = rindle::table("issues")
    .where_not_exists(|row| {
        rindle::table("comments").r#where("issue_id", row.col("id"))
    })
    .build();
```

## Aggregates: a live `count`

`count_as` attaches a **correlated child count** to each parent row as a scalar —
maintained incrementally, so adding or removing a child increments or decrements
the count without re-scanning. An empty child reads `0`.

```rust
// each issue, carrying a live count of its comments
let with_count = rindle::table("issues")
    .count_as("commentCount", |row| {
        rindle::table("comments").r#where("issue_id", row.col("id"))
    })
    .build();
```

The same shape in the JS builder — the alias resolves to a `number`, not an array:

```ts
const view = store.query.issue
  .countAs("commentCount", comment, { parent: ["id"], child: ["issueID"] })
  .materialize();
// rows: ( …Issue & { commentCount: number } )[]
```

`count` is the only aggregate today; `sum` / `avg` / `min` / `max` are designed
but not yet built, and the aggregate is a **relationship** count keyed on the
correlation — a top-level `count(table)` over a whole table is not exposed.

## Scalar subqueries: fold a unique lookup at build time

When an `EXISTS` child binds a **statically-unique key** (a primary key or a
unique index, fully pinned to constants), you can mark it `scalar` — the resolver
reads that one row **once at build time**, inlines its correlation value as a
literal, and deletes the join entirely. The parent pipeline never subscribes to
the child table.

```rust
use rindle::ExistsOpts;

// only the project that owns issue #7 — resolved once, then a plain literal filter
let owner = rindle::table("projects")
    .where_exists_with(
        |row| rindle::table("issues").r#where("id", 7).r#where("project_id", row.col("id")),
        ExistsOpts { scalar: true },
    )
    .build();
```

```ts
import { exists } from "@rindle/client";

store.query.project.where(
  exists(issue, { parent: ["id"], child: ["projectId"] }, (i) => i.where.id(7), { scalar: true }),
);
```

The trade-off is **snapshot semantics**: the inlined value is frozen for the
pipeline's lifetime, so changes to the child after build do not propagate. Leave
`scalar` off (the default) for an ordinary live `EXISTS` join.

## Aggregates

`count_as` attaches a live count to each parent row. A query can also *be* the
aggregate: `count()` reshapes the result to count rows instead of materializing
them, `group_by` keys it, and `having` filters the post-aggregation rows — all
maintained incrementally, like any other shape:

```rust
// one [count] row, maintained as rows enter and leave the filter
let ast = table("issue").r#where("open", true).count().build();

// one [status, count] row per distinct status, HAVING count > 3
let ast = table("issue")
    .group_by("status")
    .count()
    .having(|c| c.where_op("count", ">", 3))
    .build();

// filter the PARENT by a child count — issues with more than 10 comments.
// The alias must name a `count_as` relationship already on this query.
let ast = table("issue")
    .count_as("comments", |row| table("comment").r#where("issue_id", row.col("id")))
    .having_count("comments", ">", 10)
    .build();
```

`having` addresses the aggregate's *output* columns — the `group_by` columns and
the synthetic `count` — while `where` filters base rows below the aggregation.
`having_count` gates a *parent* row by a child relationship's count; v1 accepts
**high-pass** predicates only (see the matrix and rejections below).

## Supported shapes

| Query shape | fetch | push | view | Notes |
|---|:---:|:---:|:---:|---|
| Simple `where` (`=`,`!=`,`<`,`>`,`<=`,`>=`) | ✅ | ✅ | ✅ | via `.r#where` / `.where_op` |
| `IS` / `IS NOT` (null-aware, three-valued) | ✅ | ✅ | ✅ | matches SQLite's three-valued logic |
| `LIKE` / `ILIKE` / `NOT ILIKE`, incl. `\%`/`\_`/`\\` escapes | ✅ | ✅ | ✅ | memory matcher agrees with SQLite |
| `AND` / `OR` of leaf conditions | ✅ | ✅ | ✅ | |
| `IN` / `NOT IN` over a literal list | ✅ | ✅ | ✅ | via `.where_in` |
| Sibling relationships (multiple `sub` on a row) | ✅ | ✅ | ✅ | |
| Nested relationships (`sub` with its own `sub`) | ✅ | ✅ | ✅ | |
| `start_at` / `start_after` paging bound | ✅ | ✅ | ✅ | |
| `limit` (ordered take / exists cap) | ✅ | ✅ | ✅ | via `.limit` |
| `where_exists` (correlated EXISTS) | ✅ | ✅ | ✅ | the engine picks the cheaper drive side (parent- or child-driven) internally |
| `where_not_exists` (NOT EXISTS) | ✅ | ✅ | ✅ | |
| Top-level `OR` fan of EXISTS conditions | ✅ | ✅¹ | ✅ | |
| Nested `OR`/`AND` mix of EXISTS conditions | ✅ | ✅¹ | ✅ | including AND-within-AND |
| Multi-EXISTS under top-level `AND`/`OR` | ✅ | ✅ | ✅ | aliases uniquified to distinct query-local slots |
| Deepest-nested child push | ✅ | ✅ | ✅ | emits `CaughtChange::Child` |
| Self-join (reentrant fetch-during-push) | ✅ | ✅ | ✅ | |
| Many-to-many through a junction table | ✅ | ✅ | ✅ | nest `sub` through the junction; junction rows materialize uncollapsed (no hidden-edge magic) |
| Top-level `.one()` (singular root) | ✅ | ✅ | ✅ | caps the query to limit 1; the JS view unwraps to `row \| null` |
| Relationship-level `.one()` (a singular `sub`) | ⚠️ | ⚠️ | ⚠️ | view layer implemented + unit-tested, not yet reachable via a query (builds plural today) |
| Aggregate: `count` of a correlated child (`count_as`) | ✅ | ✅ | ✅ | a scalar count per parent row, incrementally maintained as the child changes. `sum` / `avg` / `min` / `max` are designed but not built yet |
| Top-level `count()` (global aggregate) | ✅ | ✅ | ✅ | reshapes the result to one `[count]` row instead of materializing rows |
| `group_by` + `count()` (grouped aggregate) | ✅ | ✅ | ✅ | one `[group…, count]` row per distinct value-tuple, keyed and sorted by the group columns |
| `having` (filter post-aggregation rows) | ✅ | ✅ | ✅ | clauses address the `group_by` columns and the synthetic `count` column |
| `having_count` (filter a parent by a child count) | ⚠️ | ⚠️ | ⚠️ | gates a parent by a `count_as` alias, maintained incrementally; **v1: high-pass predicates only** — see rejections below |
| Scalar subquery (`exists` with `scalar: true`) | ✅ | —² | ✅ | a build-time **snapshot**: a unique-key match is folded to a literal and the join is removed |
| Projection / column pruning (`select`) | ✅ | ✅ | ✅³ | `.select` drives **what syncs** — over the wire, into the client engine, and out to the view; a query never resolves a row from a column it did not select |
| Static / bound parameters | ❌ | ❌ | ❌ | a deprecated upstream (ZQL) form, not represented — permission subqueries instead carry the AST's `system: "permissions"` provenance tag and are pruned from sync |

¹ **EXISTS under a union fan, on push — a deliberate divergence from upstream.**
For one internal lowering of an EXISTS under a top-level `OR` fan, Zero's JS
engine (which Rindle ports) emits a push result that *violates* the IVM
contract; Rindle upholds *view-after-push == fresh-query*, pinned by a dedicated
consistency test (`union_fan_consistency`). So the ✅ is real, but on this one
shape Rindle intentionally does **not** match upstream ZQL output.

² **A scalar subquery does not push.** That is the point: it is resolved **once,
at build time**, inlined to a literal, and the join is deleted — so the parent
pipeline never subscribes to the child table and later changes to it do not
propagate. Opt in per-condition (`scalar: true`); leave it off for a live join.

³ **Projection's two remaining follow-ups.** The selection already shapes what
syncs end to end. Still outstanding (pure optimizations, not correctness): the
SQLite **leaf read-narrowing** (the server still `SELECT`s the full declared
column list from disk) and narrowing the **local view's reported column set**.
Neither changes results.

## Relationship slots are query-local

When two or more EXISTS conditions sit under a top-level `AND` / `OR`, the
builder uniquifies their aliases to distinct slots (`comments` → `comments_0`,
the next → `_1`, …). The slot layout is **derived from the query AST**, not from
the source schema's declared relationships — so a production-shaped schema that
declares only the table's real relationship names (or none) builds these shapes,
and the wasm path needs no synthesized gate slots. The slot order is
materialized relationships first, then EXISTS gates in `where`-tree pre-order —
the one tree shared by the dataflow joins, the EXISTS gates, and the view
materialization, so their relationship ids agree by construction.

## Build-time rejections

These are genuine limitations surfaced as a `BuildError`, not normalization
artifacts:

- **A root aggregate combined with row-shaping** — pairing `count()` with
  `select` / `sub` / `count_as` / `order_by` / `limit` / `one` is rejected: a
  `count()` query's result *is* the aggregate output (`group_by` columns +
  `count`), not rows.
- **A low-pass `having_count` predicate** — one that is *true* at count 0
  (`<= n`, `< n` for `n ≥ 1`, `= 0`, `>= 0`) → `BuildError::Unsupported`. A
  childless parent forms no group, so those need row-widening (deferred); the
  high-pass set (`> n`; `>=` / `=` / `!=` for `n ≥ 1`) builds and maintains.
- **A child-driven `NOT EXISTS`** — the engine maintains `NOT EXISTS`
  parent-driven only, and nothing in the fluent builder or the planner ever asks
  for the child-driven form; a hand-authored wire `Ast` that marks one is
  rejected with `BuildError::Unsupported("flipped NOT EXISTS is not lowered")`.
- **An EXISTS subquery carrying a paging bound or a nested relationship** →
  `BuildError::Unsupported`.
- **A bare top-level EXISTS aliased the same as a materialized relationship** →
  `BuildError::Unsupported` (one relationship per slot). A bare EXISTS is not
  alias-uniquified, so it collides with a `sub` of the same name; two EXISTS
  under a top-level `and` / `or` are uniquified to distinct slots and never
  collide.

Unknown tables and columns are also build-time errors: referencing a table you
never registered fails with a `BuildError` at `Db::query` time, and an undeclared
relationship name surfaces as `BuildError::UnknownRelationship`.

## A note on value types

Source values arrive from SQLite, where the engine's `number` domain vends as
`OwnedValue::Float` — even a column you populated with an integer literal reads
back as `OwnedValue::Float` in the change stream. The other owned cell shapes are
`OwnedValue::Int`, `OwnedValue::Bool`, `OwnedValue::Null`, and the string
constructor `OwnedValue::str("…")`. Match defensively:

```rust
use rindle::OwnedValue;

fn id_of(row: &[OwnedValue]) -> i64 {
    match &row[0] {
        OwnedValue::Float(f) => *f as i64,
        OwnedValue::Int(i) => *i,
        other => panic!("unexpected id cell: {other:?}"),
    }
}
```

## Next steps

- [Quickstart](/docs/quickstart) — stand up a live query end to end.
- [The change model](/docs/change-model) — `Add` / `Remove` / `Edit` / `Child` in depth.
- [Replica and views](/docs/replica-and-views) — how deltas are derived and delivered.
- [Crates](/docs/crates) — `rindle`, `rindle-replica`, and `rindle-sqlite`.

---

[View this page on Rindle](https://rindle.sh/docs/supported-queries)
