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:
| Flag | Items are | Filter with |
|---|---|---|
| (none) | Plain values | filter.eq / filter.in |
isDistribution | Ranges — dates, sizes — whose value is a server-built expression | filter.distribution |
isTree | Nodes of a hierarchy, expanded a level at a time | filter.eq on the node's path |
valuesAreExpressions | Expressions; compare items on display, not value | see below |
Getting this wrong has one loud symptom: 500 — "Field type error". See
Filtering results.
Rendering a facet
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))),
});
}import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from "@angular/core";
import { aggregationItemStrictCompare, sysLang } from "@sinequa/atomic";
import type { Aggregation, AggregationItem } from "@sinequa/atomic";
import { SINEQUA } from "./sinequa";
@Component({
selector: "app-facet",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h3>{{ label(aggregation().name) }}</h3>
<ul>
@for (item of items(); track item.value) {
<li>
<button type="button" [attr.aria-pressed]="isSelected(item)" (click)="toggle.emit(item)">
{{ label(item.display ?? item.value) }} ({{ item.count }})
</button>
</li>
}
</ul>
<!-- $hasMore is set by the library from the item count, not by the server. -->
@if (aggregation().$hasMore) {
<button type="button" (click)="loadMore()" [disabled]="loading()">
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
`,
})
export class FacetComponent {
private readonly client = inject(SINEQUA);
readonly aggregation = input.required<Aggregation>();
readonly selected = input<AggregationItem[]>([]);
readonly loading = signal(false);
private readonly extra = signal<AggregationItem[]>([]);
readonly items = computed(() => [...(this.aggregation().items ?? []), ...this.extra()]);
/** Compares on `display` or `value` depending on the aggregation — hence the helper. */
isSelected(item: AggregationItem) {
return this.selected().some((s) => aggregationItemStrictCompare(this.aggregation(), s, item));
}
label(value: unknown) {
return sysLang(String(value ?? ""), navigator.language);
}
/**
* Pages through this aggregation's items. The page size comes from the declared
* `aggregationCounts`; pass a 4th argument to override it for this call only.
*/
async loadMore() {
this.loading.set(true);
try {
const next = await this.client.api.query.aggregate(this.aggregation(), {
name: "<your-query>",
text: "report",
});
this.extra.update((current) => [...current, ...(next.items ?? []).slice(current.length)]);
} finally {
this.loading.set(false);
}
}
}import { aggregationItemStrictCompare, sysLang } from "@sinequa/atomic";
import type { Aggregation, AggregationItem } from "@sinequa/atomic";
import { useState } from "react";
import { sinequa } from "./sinequa";
export function Facet({
aggregation,
selected,
onToggle,
}: {
aggregation: Aggregation;
selected: AggregationItem[];
onToggle: (item: AggregationItem) => void;
}) {
const [extra, setExtra] = useState<AggregationItem[]>([]);
const [loading, setLoading] = useState(false);
const items = [...(aggregation.items ?? []), ...extra];
/**
* Pages through this aggregation's items. The page size comes from the declared
* `aggregationCounts`; pass a 4th argument to override it for this call only.
*/
async function loadMore() {
setLoading(true);
try {
const next = await sinequa.api.query.aggregate(aggregation, { name: "<your-query>", text: "report" });
setExtra((current) => [...current, ...(next.items ?? []).slice(current.length)]);
} finally {
setLoading(false);
}
}
return (
<>
<h3>{sysLang(aggregation.name, navigator.language)}</h3>
<ul>
{items.map((item) => (
<li key={String(item.value)}>
<button
type="button"
/* Compares on display or value depending on the aggregation. */
aria-pressed={selected.some((s) => aggregationItemStrictCompare(aggregation, s, item))}
onClick={() => onToggle(item)}
>
{sysLang(String(item.display ?? item.value), navigator.language)} ({item.count})
</button>
</li>
))}
</ul>
{/* $hasMore is set by the library from the item count, not by the server. */}
{aggregation.$hasMore && (
<button type="button" onClick={loadMore} disabled={loading}>
{loading ? "Loading…" : "Load more"}
</button>
)}
</>
);
}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:
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.
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]),
),
});
})();import { InjectionToken, inject, provideAppInitializer } from "@angular/core";
import { type AtomicClient, createAtomicClient } from "@sinequa/atomic";
export const SINEQUA = new InjectionToken<AtomicClient>("Sinequa client", {
factory: () =>
createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
}),
});
export function provideSinequa() {
return provideAppInitializer(async () => {
const client = inject(SINEQUA);
await client.initializeConfig();
await client.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 client.api.app.get();
const queryName = app.defaultQueryName ?? "<your-query>";
client.configure({
aggregationCounts: Object.fromEntries(
(app.queries[queryName].aggregations ?? []).map((a) => [a.name, a.count]),
),
});
});
}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":
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.