@sinequa/atomic

Aggregations

Rendering facets, loading more items, expanding tree facets, and knowing which kind of aggregation you are holding.

Facet counts arrive with the search response, in result.aggregations. You do not request them; you render them, and you re-run the search when one is clicked.

Concept — aggregation

An aggregation counts documents per value of an index column, computed over the whole result set. "PDF (412), Word (87)" in a sidebar is an aggregation on docformat. Which ones exist is decided by the query web service, not by your code.

Three kinds, and how to tell them apart

The flags on an aggregation change what its items mean — read them before rendering:

FlagItems areFilter with
(none)Plain valuesfilter.eq / filter.in
isDistributionRanges — dates, sizes — whose value is a server-built expressionfilter.distribution
isTreeNodes of a hierarchy, expanded a level at a timefilter.eq on the node's path
valuesAreExpressionsExpressions; compare items on display, not valuesee below

Getting this wrong has one loud symptom: 500 — "Field type error". See Filtering results.

Rendering a facet

A facet list with selection and counts
src/facet.ts
import { aggregationItemStrictCompare, createAtomicClient, filter, sysLang } from "@sinequa/atomic";
import type { Aggregation, AggregationItem, Filter } from "@sinequa/atomic";

const client = createAtomicClient({
  app: "<your-app>",
  backendUrl: "https://<your-sinequa-server>",
});

await client.initializeConfig();
await client.auth.login();

const selected: AggregationItem[] = [];

let result = await client.api.query.search({ name: "<your-query>", text: "report", pageSize: 10 });

const formats = result.aggregations.find((a) => a.column === "docformat");
if (formats) render(formats);

function render(aggregation: Aggregation) {
  for (const item of aggregation.items ?? []) {
    // `display` is a multilingual string; decode it before showing it.
    const label = sysLang(String(item.display ?? item.value), navigator.language);

    // Comparing on the right field is what this helper is for: an aggregation whose
    // values are expressions must be compared on `display`.
    const isSelected = selected.some((s) => aggregationItemStrictCompare(aggregation, s, item));

    console.log(`${isSelected ? "[x]" : "[ ]"} ${label} (${item.count})`);
  }

  // More items exist than were returned — set by the library, not the server.
  if (aggregation.$hasMore) console.log("… load more");
}

/** The filter for one clicked item, whatever kind of facet it came from. */
function filterFor(aggregation: Aggregation, item: AggregationItem): Filter | undefined {
  if (aggregation.isDistribution) {
    const parsed = filter.distribution(aggregation.column, item.value);
    if (parsed) return parsed;
  }
  return item.value === null
    ? filter.isNull(aggregation.column)
    : filter.eq(aggregation.column, item.value);
}

export async function toggle(aggregation: Aggregation, item: AggregationItem) {
  const index = selected.findIndex((s) => aggregationItemStrictCompare(aggregation, s, item));
  if (index >= 0) selected.splice(index, 1);
  else selected.push(item);

  result = await client.api.query.search({
    name: "<your-query>",
    text: "report",
    pageSize: 10,
    // `and` drops the undefined entries, so an empty selection means no filter.
    filters: filter.and(...selected.map((item) => filterFor(aggregation, item))),
  });
}

Restoring selection after a reload

A reloaded search — a saved search, a shared URL, sessionStorage — carries its selection as query.filters, not as an AggregationItem[] you already had in memory at click time. aggregationItemStrictCompare cannot help here: it compares two items, and a filter leaf is not one — only its value and display are.

flattenLegacyFilterValues(filters) flattens a LegacyFilter tree (and/or, the legacy .filters array) into the flat list of every leaf value and display it carries, so a plain membership check restores the same selection aggregationItemStrictCompare would have given at click time:

src/restore-selection.ts
import { flattenLegacyFilterValues } from "@sinequa/atomic";
import type { Aggregation, LegacyFilter } from "@sinequa/atomic";

function markSelected(aggregation: Aggregation, stored: LegacyFilter | undefined) {
  const flattened = stored ? flattenLegacyFilterValues([stored]) : [];

  for (const item of aggregation.items ?? []) {
    // Same rule as aggregationItemStrictCompare: compare on display for a distribution
    // bucket (or any valuesAreExpressions aggregation), on value otherwise.
    const key = aggregation.valuesAreExpressions ? item.display : item.value;
    item.$selected = flattened.includes(String(key ?? ""));
  }
}

This is what a facet built on LegacyFilter-shaped stored state — see URL state — needs to highlight the right buckets again. A plain item.value === filter.value fails for a distribution: the stored filter only ever carries the bucket's numeric thresholds (10240, 102400), never the expression the item itself holds.

Loading more items

client.api.query.aggregate(aggregation, query, audit?, count?) pages through one aggregation. It computes skip from how many items the aggregation already holds, asks for the next slice, and returns the aggregation with them appended.

The page size is the administrator's decision

Each facet carries a count in the query web service's configuration — the maximum number of values to retrieve — and the query response does not repeat it. There is no field on the aggregation that says how many values were asked for, so the library cannot discover it: you tell it once.

Declare the real page sizes at start-up
src/sinequa.ts
import { createAtomicClient } from "@sinequa/atomic";

export const sinequa = createAtomicClient({
  app: "<your-app>",
  backendUrl: "https://<your-sinequa-server>",
});

export const ready = (async () => {
  await sinequa.initializeConfig();
  await sinequa.auth.login();

  // Read the configuration once, and hand the real page sizes to the library.
  // Without this it assumes 10, which makes `$hasMore` wrong both ways.
  const app = await sinequa.api.app.get();
  const queryName = app.defaultQueryName ?? "<your-query>";

  sinequa.configure({
    aggregationCounts: Object.fromEntries(
      (app.queries[queryName].aggregations ?? []).map((a) => [a.name, a.count]),
    ),
  });
})();

The count used for one aggregation is resolved from the most specific source available:

Prop

Type

resolveAggregationCount(name, query?, counts?) and aggregationHasMore(aggregation, count) are exported, for code that needs the same reasoning outside a fetch* call.

How the server signals that more exist

There is no "total values" field. Instead, the server returns one item beyond the count it was asked for when values remain: ask for 10 and you receive 11, the eleventh being a sentinel that means "there is more".

That extra item is the only signal available, so the test is items.length > count — never an equality, which would hold for exactly one configured count and leave every other facet silently claiming it is complete. $hasMore is computed that way, and it is a client-side flag: the server never sends it.

The response to an aggregate query is not a Result

A query with action: "aggregate" answers with id, queryName, aggregations and attributes — no records, no rowCount, no page. Type such a response as AggregateResult; see Result anatomy.

Tree facets

A tree aggregation covers a hierarchical column such as treepath, and is expanded one level at a time with action: "open":

expanding a node
const result = await client.api.query.search({
  name: "<your-query>",
  text: "report",
  action: "open",
  open: [{ expression: "treepath:/HR/Contracts/*", aggregation: "Tree" }],
});

The library annotates each node as it goes: $path and $level are computed on the way back, hasChildren says whether it can be expanded, and $opened tracks what the user unfolded. Those are the fields to render from — you do not walk the paths yourself.

openParentNodes(node) reopens the ancestors of a selected node, which is what restores a tree's state after a reload. Note that it mutates the tree in place rather than returning a new one.

Pitfalls

What's next

On this page