Rindle maintains aggregates as source rows change. Use a relationship count to attach a number to each parent, or a root count to return aggregate rows.
The public aggregate surface differs by language:
| Result | TypeScript builder | Rust builder |
|---|---|---|
| Count matching rows | count() |
count() |
| Grouped count | groupBy(...).count() |
group_by(...).count() |
| Count related children | countAs(...) |
count_as(...) |
| Sum or average | Not currently exposed | sum, avg, sum_as, avg_as |
| Minimum or maximum | Not currently exposed | Not currently exposed |
This guide starts with a complete TypeScript example. The Rust query reference covers its additional aggregate methods.
Run a local count
Use the browser store setup, then put this code in your entry module. It defines both tables and observes the counts after writes:
import {
boolean, createSchema, createWasmStore, gt, number, string, table,
} from "@rindle/wasm";
const issue = table("issue").columns({
id: number(), title: string(), open: boolean(), status: string(),
}).primaryKey("id");
const comment = table("comment").columns({
id: number(), issueId: number(), body: string(),
}).primaryKey("id");
const schema = createSchema({ tables: [issue, comment] });
const store = await createWasmStore(schema);
const perIssue = store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] })
.orderBy("id", "asc")
.materialize();
const openCount = store.query.issue.where.open(true).count().materialize();
const byStatus = store.query.issue
.groupBy("status")
.count()
.having((h) => h.count(gt(0)))
.materialize();
const unsubscribe = perIssue.subscribe((rows) => console.log("issues", rows));
await store.write((tx) => {
tx.add("issue", { id: 1, title: "First", open: true, status: "todo" });
tx.add("issue", { id: 2, title: "Second", open: false, status: "done" });
tx.add("comment", { id: 10, issueId: 1, body: "Ready" });
tx.add("comment", { id: 11, issueId: 1, body: "Reviewed" });
});
console.log(openCount.data); // [{ count: 1 }]
console.log(byStatus.data); // one count row for "done" and one for "todo"
console.assert(perIssue.data[0]?.commentCount === 2);
console.assert(perIssue.data[1]?.commentCount === 0);
await store.write((tx) => tx.remove("comment", {
id: 11, issueId: 1, body: "Reviewed",
}));
console.assert(perIssue.data[0]?.commentCount === 1);
unsubscribe();
perIssue.destroy();
openCount.destroy();
byStatus.destroy();
countAs keeps each issue row and adds a scalar commentCount. It produces 0
for an issue without comments. count() returns one { count } row, including
{ count: 0 } when no rows match. A grouped count produces one row per existing
group; a group disappears when its last row leaves.
where filters input rows before aggregation. having filters the aggregate
output. The result still updates from source changes, but hydration must first
read and aggregate the matching input. One change can affect several parents.
Reuse a relationship
A named relationship avoids repeating its key mapping:
import { defineRelationships, rel } from "@rindle/client";
const relationships = defineRelationships({
issueComments: rel(issue, comment, { id: "issueId" }),
});
const query = store.query.issue.countAs("commentCount", relationships.issueComments);
This fragment reuses the tables and store above. It builds a query without
materializing another view. countAs also accepts a child builder for a filtered
count, such as counting only comments that match a predicate.
Filter parents by child count
The parent-query having(alias, operator, value) overload refers to an existing
countAs alias:
const activeIssues = store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] })
.having("commentCount", ">", 10);
This returns issues with more than ten comments. It keeps the full count on each
surviving issue. It differs from .having((h) => ...), which filters root
aggregate rows.
The parent-count filter currently accepts one numeric comparison that is false
at zero. > 0, >= 2, = 2, and != 0 work. Predicates such as = 0, >= 0,
and != 1 are rejected because a childless parent has no aggregate group to test.
Use counts in a synced app
Put the aggregate in a named query when the server must supply its data. Keep that query’s subscription active while the UI needs updates. A bare local query counts the rows available in the local store; it does not request all matching server rows.
The sync path can supply precomputed aggregate rows. A server count can therefore cover more children than the browser materializes. Do not infer that every counted child is available for a local detail query. Request that detail through its own authorized named query.
Current limits
A root aggregate cannot also select base columns, attach relationships, sort or
limit groups, use paging, or apply one(). Root aggregates also reject correlated
subqueries in where or having. Group columns and the synthetic aggregate
column form the output.
The TypeScript builder currently exposes counts only. Rust’s sums and averages
ignore null inputs and return NULL for empty or all-null input. These are public
Rust methods, not TypeScript methods with different spelling.
See TypeScript query shapes and Rust query shapes for supported combinations.