logo
27 Jun 2026

The Ontology, Part 3: How You Ask It Questions

Part 3 of the beginner's guide to ontologies. How you read: build an object set — a lazy, reusable description — then ask it for a page, a count, an aggregation, or a search. Filters, traversal, ordering, pagination, derived properties — with an interactive playground, then a concrete REST contract.

This is part 3. Part 1 covered what an ontology is — objects, links, actions, security — and Part 2 covered how it's consumed. This time: the single most important thing for anyone building on top of it — how you read. Every screen, every list, every chart, every agent answer starts with a query. Get the query model right and the whole frontend falls out of it. We'll build the intuition with Mai and Hamed, play with it live, then the appendix pins it down as an actual API contract you could implement.


The big idea: you don't write SQL, you build an object set

Here's the mental shift. You're not writing a query string that runs once and vanishes. You build an object set — a description of a slice of your data — and then you ask that description for things.

The description is lazy. Nothing is fetched when you build it. It's a recipe, not the cooked dish. Only when you ask it a concrete question ("give me the first page," "how many," "group these by status") does anything actually run.

👩‍💻 Mai (junior): Why not just send a query and get rows back, like SQL?

👨‍🏫 Hamed (mentor): Because an object set is composable and reusable. You build "open orders over $100," and then you can hand that same thing around — page it on one screen, count it on a badge, chart it on a dashboard — without rewriting the filter three times. One description, many questions. SQL makes you rewrite the FROM/WHERE every time; this doesn't.

You build a set by stacking operations, and every operation just returns another description — still nothing fetched:

Object set start: all Orders filter status = open filter total > 100 order by date, desc Still just a description — nothing has been fetched yet.

In code, on the client, that's a fluent, immutable builder:

const openOrders = client.objects(Order)
  .where({ status: { eq: "open" }, total: { gt: 100 } })
  .orderBy("createdAt", "desc");
// Nothing has run. openOrders is a description you can pass around.

One description, many questions

Now the payoff. That one openOrders set answers several different questions, each a different "terminal" operation that actually hits the server:

Object set the description fetchPage() a page of objects count() a single number aggregate() groups / a chart
const page     = await openOrders.fetchPage({ pageSize: 25 });   // objects
const total    = await openOrders.count();                       // a number
const byStatus = await openOrders.aggregate({                    // a chart
  groupBy: "status",
  metrics: ["count"],
});

This is the reason to model reads as object sets rather than one-off queries. A table, a count badge, and a chart on the same screen are three terminal calls on one shared description — not three hand-written queries that can drift out of sync.

Try it. The playground below is this model, live. Build an object set step by step, then press Run to materialize it — and watch the engine console trace every stage of the pipeline. It's also where Part 1's ideas come alive: flip View as to watch the security gate hide rows and mask columns, and acknowledge an alert to see an action's optimistic write (and a rollback when it's rejected).

A self-contained ontology query playground (construction-site example). Open full screen ↗

Filters: how you narrow

A filter is a tree of predicates. At the leaves are simple comparisons on a property; above them, boolean glue. The operator vocabulary you need to cover everything:

  • Equality and sets: eq, neq, in
  • Ordering: lt, lte, gt, gte, between (ranges — numbers, dates)
  • Null checks: isNull
  • Text: contains, startsWith, endsWith
  • Geo: withinRadius, withinBbox
  • Relationship existence: hasLink (objects that have a link to some other set)
  • Composition: and, or, not

👩‍💻 Mai: What about "customers who have at least one open order"? That's not a property on the customer.

👨‍🏫 Hamed: Right — that's a relationship filter, and it's where this gets more powerful than a flat WHERE clause. You filter one set by the existence of a link into another set. Which brings us to the real superpower: traversal.

Traversal: walking the graph

Because objects are linked, a query can move across the graph. The key rule that keeps it simple: traversing a link gives you back another object set. Start with a set of Customers, follow their orders link, and now you have a set of Orders — which you can filter, order, and paginate exactly like any other set.

Customers an object set their Orders another object set follow: orders Traversal returns a set — so you keep filtering, ordering, paginating.
const hongkongOpenOrders = client.objects(Customer)
  .where({ branch: { eq: "Hong Kong" } })
  .traverse("orders")                 // now an object set of Orders
  .where({ status: { eq: "open" } }); // keep narrowing — it's just a set

That uniformity — traversal in, set out — is what lets the frontend treat "a customer's orders" and "all orders" identically. Same component, same hook, same pagination.

Ordering and pagination

Two boring-but-critical mechanics.

Ordering is a list of keys (property + direction), applied in order. One rule that saves you from flaky lists: always tie-break on the primary key. Two orders with the same timestamp must come back in a stable order, every time, or pagination breaks.

Pagination is cursor-based, not offset-based. You ask for a pageSize and get back a nextPageToken; you pass that token to get the next page. Why not LIMIT/OFFSET? Because data changes under you — if someone inserts a row while a user is scrolling, offset-based paging silently skips or repeats items. A cursor points at "where you were," so it stays correct even as the underlying set shifts. It's also far cheaper at scale.

Aggregations: ask for numbers, not objects

Sometimes you don't want the objects — you want a summary. Aggregations run on the server over the whole set (not just one page) and return groups and metrics:

const revenueByMonth = await openOrders.aggregate({
  groupBy: [{ property: "createdAt", bucket: { dateHistogram: "month" } }],
  metrics: [{ sum: "total" }, "count"],
});

Metrics cover count, sum, avg, min, max, and approxDistinct. Grouping is either by an exact property value or by a bucket — numeric ranges, or a date histogram (day/week/month). This is what powers every dashboard tile without shipping thousands of objects to the browser.

Search: when the user types words, not filters

Filters are for precise, structured narrowing. Search is for fuzzy, human input — a search box. It runs full-text (and typically prefix/fuzzy) matching across the properties you've marked searchable, ranks the results, and can be combined with a regular filter:

const results = client.objects(Order)
  .search("acme refund")               // ranked, fuzzy
  .where({ status: { neq: "archived" } }); // still composable

Keep the distinction clean: filters answer "exactly these," search answers "best matches for these words."

Derived and computed properties

Not every property is stored. A derived property is computed — daysOpen, riskScore, fullName — by a function defined in the ontology. The important part for the query model: once defined, a derived property behaves like any other. You can select it, filter on it, and order by it:

client.objects(Order)
  .where({ daysOpen: { gt: 30 } })   // filter on a computed value
  .orderBy("riskScore", "desc");     // order by another

The only caveat is cost — a derived value may be computed at query time, so filtering/sorting on it can be heavier than on a stored column. That's a performance note, not a different API.

Two things that are always true

Selection keeps payloads small. By default, don't ship every property and every linked object. Let the caller say which properties it needs and which links to expand — the table that shows three columns shouldn't download forty.

openOrders.select(["id", "status", "total"]).expand({ customer: ["name"] });

Security is not your job here — it's automatic. Every one of these calls runs through the policy gates from Part 1. An object set silently returns only the rows the caller may see, with forbidden columns omitted. You never write a permission check in a query; the set is already scoped to the caller. The frontend's only related job is to read the visibility metadata so it doesn't render a column the server was never going to return.

👩‍💻 Mai: So the whole read side is really just: build a set, then call one of a handful of terminal operations on it?

👨‍🏫 Hamed: That's it. Build → filter → traverse → shape, then fetchPage / count / aggregate / search. Learn those once and you've learned every read in the platform. And it maps one-to-one onto the frontend hooks — which is exactly why we pinned the query model down before touching React.


The one-paragraph summary

You read an ontology by composing an object set — a lazy, reusable description built from a starting object type plus filters, link traversals, ordering, and selection. You then ask that set a terminal question: a page of objects, a count, an aggregation, or a ranked search. Traversal turns one set into another, so the whole graph is reachable through one uniform model. Derived properties behave like stored ones. Pagination is cursor-based for stability. And every set is automatically scoped to what the caller is allowed to see. That single model — build a description, ask it questions — is the entire read surface, and it's what your data-layer hooks will wrap one-for-one.


Appendix — The Query API (REST contract)

This is the concrete contract to implement. REST-ish: simple reads are GET; anything with a nested filter is POST with a JSON body (query strings can't carry a filter tree). The object-set description is the central data structure; the endpoints are thin operations over it.

Conventions

  • Base path: /api/v1/ontology
  • {type} = an object type's API name (e.g. Order). {pk} = a primary key value.
  • All POST reads take a JSON body and are safe/idempotent (they don't mutate). POST is used only because the filter tree won't fit in a URL.
  • All responses are scoped to the caller's policy. Forbidden rows are absent; forbidden properties are omitted from each object (never returned as null to disguise existence).

Endpoints

MethodPathPurpose
GET/objects/{type}/{pk}Fetch one object by primary key
POST/objects/{type}/loadLoad a page of an object set
POST/objects/{type}/countCount an object set
POST/objects/{type}/aggregateAggregate an object set
POST/objects/{type}/searchRanked full-text search
POST/objects/{type}/{pk}/links/{link}/loadLoad a page of linked objects
POST/objectSets/loadLoad from a full object-set description (incl. traversal chains)

The per-type endpoints are conveniences. /objectSets/load is the general form: it accepts a complete object-set description, including traversal, so multi-hop queries have a home.

The object-set description

The body shared by load, count, and aggregate:

{
  "base": { "objectType": "Order" },
  "where": { "/* see Filter grammar below */": "..." },
  "traverse": [ { "link": "customer" } ],
  "orderBy": [ { "property": "createdAt", "direction": "desc" } ],
  "select": ["id", "status", "total"],
  "expand": { "customer": { "select": ["id", "name"] } },
  "page": { "pageSize": 25, "pageToken": null }
}
  • base — the starting object type (and optionally a starting where).
  • traverse — an ordered list of link hops. After traversal, the set's type is the final hop's target; where/orderBy/select apply to that type.
  • select / expand — projection. select lists properties to return (omit ⇒ a default set). expand pulls in linked objects with their own select.
  • orderBy — ordered keys; the server appends the primary key as a final tie-breaker.
  • pagepageSize (server clamps to a max, e.g. 1000) and the opaque pageToken.

Filter grammar

A filter is either a leaf or a composite.

{ "property": "status", "op": "eq", "value": "open" }
{ "and": [
  { "property": "status", "op": "eq", "value": "open" },
  { "property": "total",  "op": "gt", "value": 100 },
  { "not": { "property": "channel", "op": "in", "value": ["test", "demo"] } }
] }

Leaf operators:

CategoryOpsValue shape
Equality / seteq, neq, inscalar; in → array
Ordering / rangelt, lte, gt, gte, betweenscalar; between[min, max]
NullisNullboolean
Textcontains, startsWith, endsWithstring
GeowithinRadius, withinBbox{center, radius} / {bbox}
RelationshiphasLinka nested set + filter (see below)

hasLink is how you express "customers who have at least one open order" without leaving the customer set:

{ "op": "hasLink",
  "value": { "link": "orders", "where": { "property": "status", "op": "eq", "value": "open" } } }

Load response

{
  "data": [
    { "__type": "Order", "__primaryKey": "ord_9", "id": "ord_9", "status": "open", "total": 240 }
  ],
  "nextPageToken": "eyJvZmZzZXQiOiAyNX0"
}

nextPageToken is null when the last page has been returned. Clients pass it back verbatim in page.pageToken.

Count response

{ "count": 1843 }

Aggregate

Request:

{
  "where": { "property": "status", "op": "eq", "value": "open" },
  "groupBy": [
    { "property": "branch" },
    { "property": "createdAt", "bucket": { "dateHistogram": "month" } }
  ],
  "metrics": [ { "op": "count" }, { "op": "sum", "property": "total" } ]
}

Bucketing kinds: exact (no bucket), dateHistogram (day|week|month|quarter|year), and ranges. Metric ops: count, sum, avg, min, max, approxDistinct.

Response — one row per group key combination:

{
  "groups": [
    { "key": { "branch": "Hong Kong", "createdAt": "2026-05" }, "metrics": { "count": 92, "sum_total": 41200 } }
  ]
}
{ "query": "acme refund", "where": { "property": "status", "op": "neq", "value": "archived" },
  "page": { "pageSize": 20 } }

Runs full-text (prefix + fuzzy) over the type's searchable properties, combinable with a structured where. Response is a load response plus a per-object __score for ranking.

Derived properties

Derived properties (computed by an ontology function) appear in select, where, and orderBy exactly like stored ones — no special syntax. The server resolves them; treat them as potentially more expensive to filter/sort on.

Errors

{ "error": { "code": "INVALID_FILTER", "message": "Unknown property 'totl' on Order", "details": { "property": "totl" } } }

Codes: INVALID_FILTER, INVALID_ORDER, UNKNOWN_OBJECT_TYPE, UNKNOWN_LINK, PAGE_TOKEN_EXPIRED, FORBIDDEN (caller can't see the type at all), PAGE_SIZE_EXCEEDED.

Mapping to the frontend hooks

This is why the model was worth pinning down first — the hooks are one-for-one wrappers:

HookEndpointReturns
useObject(type, pk)GET /objects/{type}/{pk}one object
useObjects(set)POST /objects/{type}/loadpage + fetchMore
useLinks(object, link)POST /objects/{type}/{pk}/links/{link}/loadlinked page
useCount(set)POST /objects/{type}/counta number
useAggregation(set, spec)POST /objects/{type}/aggregategroups
useSearch(type, query)POST /objects/{type}/searchranked page

Design decisions (the "why")

  • POST for complex reads. A filter tree, traversal chain, and aggregation spec don't fit a query string. Keep GET only for fetch-by-id.
  • Object set as a first-class description. It composes (filter → traverse → filter), it's serializable (you can save/share/cache a set definition), and the server can plan/optimize the whole thing at once.
  • Cursor pagination. Stable under concurrent writes; scales past the offset cliff.
  • Stable ordering via primary-key tie-break. Non-negotiable, or pagination duplicates/skips.
  • Projection by default-narrow. select/expand exist so the common case (a few columns) doesn't drag the whole object graph over the wire.
  • Security is server-side and invisible to the query. The query never expresses permissions; the set is pre-scoped to the caller. The UI mirrors it via metadata, never enforces it.