Skip to content
Guides contents

GuidesQueries & schemas

Live counts & aggregates

Maintain counts and grouped aggregates as rows change, including counts attached to related parent rows.

View as Markdown

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.