Filtering results
Turn facet clicks, checkboxes and date ranges into a query's filters, without writing filter objects by hand.
A search rarely stays a plain text query. The user ticks "PDF only", clicks a folder in a tree, picks "last
month" — and each of those has to reach the server as a condition on the results. That condition is the
filters property of the query, and the filter builder is what produces it.
Concept — fielded search
A Sinequa index stores documents as rows with typed columns: docformat, modified, size, treepath,
authors, plus whatever the administrator mapped from the source. Filtering means constraining one of those
columns — which is why a filter always names a column, never a display label.
Minimal example
import { createAtomicClient, filter } from "@sinequa/atomic";
import type { Query, Result } from "@sinequa/atomic";
const client = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
async function main() {
// Reads the server's pre-login configuration and settles how to authenticate.
await client.initializeConfig();
if (!(await client.auth.login())) {
throw new Error("No session. See the Authentication guide for your deployment's mode.");
}
const query: Query = {
name: "<your-query>", // the query web service configured on the server
text: "quarterly report",
pageSize: 10,
filters: filter.eq("docformat", "pdf"),
};
render(await client.api.query.search(query));
}
function render(result: Result) {
// A failed request also comes back with no records: tell the two apart first.
if (result.$error) {
console.error("Search failed:", result.$error.message);
return;
}
for (const record of result.records) {
console.log(`${record.title} — ${record.docformat}`);
}
console.log(`${result.rowCount} matching document(s)`);
}
main().catch((error) => console.error(error));import { InjectionToken, inject, provideAppInitializer } from "@angular/core";
import { type AtomicClient, createAtomicClient } from "@sinequa/atomic";
/** The client, injectable anywhere. One instance for the whole application. */
export const SINEQUA = new InjectionToken<AtomicClient>("Sinequa client", {
factory: () =>
createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
}),
});
/**
* Resolves the configuration and the session before the first view renders, so no
* component has to guard against an unauthenticated client.
*/
export function provideSinequa() {
return provideAppInitializer(async () => {
const client = inject(SINEQUA);
await client.initializeConfig();
await client.auth.login();
});
}import type { ApplicationConfig } from "@angular/core";
import { provideSinequa } from "./sinequa";
export const appConfig: ApplicationConfig = {
providers: [provideSinequa()],
};import { ChangeDetectionStrategy, Component, inject, resource, signal } from "@angular/core";
import { filter } from "@sinequa/atomic";
import { SINEQUA } from "./sinequa";
@Component({
selector: "app-results",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label>
<input type="checkbox" [checked]="pdfOnly()" (change)="pdfOnly.set(!pdfOnly())" />
PDF only
</label>
@if (results.isLoading()) {
<p>Searching…</p>
} @else if (results.error()) {
<p>Search failed: {{ results.error()?.message }}</p>
} @else {
<p>{{ results.value()?.rowCount ?? 0 }} matching document(s)</p>
<ul>
@for (record of results.value()?.records ?? []; track record.id) {
<li>{{ record.title }} — {{ record.docformat }}</li>
}
</ul>
}
`,
})
export class ResultsComponent {
private readonly client = inject(SINEQUA);
readonly text = signal("quarterly report");
readonly pdfOnly = signal(false);
readonly results = resource({
params: () => ({ text: this.text(), pdfOnly: this.pdfOnly() }),
loader: ({ params }) =>
this.client.api.query.search({
name: "<your-query>",
text: params.text,
pageSize: 10,
// An unchecked box is `false`, which `and` drops — no branch needed here.
filters: filter.and(params.pdfOnly && filter.eq("docformat", "pdf")),
}),
});
}import { createAtomicClient } from "@sinequa/atomic";
export const sinequa = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
/**
* Started once at module load, awaited by every component that queries. Keeping it
* a promise rather than a state flag means concurrent components share one sign-in
* instead of racing to start their own.
*/
export const ready = (async () => {
await sinequa.initializeConfig();
await sinequa.auth.login();
})();import { filter } from "@sinequa/atomic";
import type { Result } from "@sinequa/atomic";
import { useEffect, useState } from "react";
import { ready, sinequa } from "./sinequa";
export function SearchResults() {
const [pdfOnly, setPdfOnly] = useState(false);
const [result, setResult] = useState<Result>();
const [error, setError] = useState<Error>();
useEffect(() => {
let active = true;
setError(undefined);
ready
.then(() =>
sinequa.api.query.search({
name: "<your-query>",
text: "quarterly report",
pageSize: 10,
// An unchecked box is `false`, which `and` drops — no branch needed here.
filters: filter.and(pdfOnly && filter.eq("docformat", "pdf")),
}),
)
.then((next) => {
if (active) setResult(next);
})
.catch((cause: unknown) => {
if (active) setError(cause as Error);
});
// The checkbox can flip before the previous search answers; ignore the stale one.
return () => {
active = false;
};
}, [pdfOnly]);
if (error) return <p>Search failed: {error.message}</p>;
if (!result) return <p>Searching…</p>;
return (
<>
<label>
<input type="checkbox" checked={pdfOnly} onChange={() => setPdfOnly(!pdfOnly)} />
PDF only
</label>
<p>{result.rowCount} matching document(s)</p>
<ul>
{result.records.map((record) => (
<li key={record.id}>
{record.title} — {record.docformat}
</li>
))}
</ul>
</>
);
}How it works
filter.* returns plain objects. There is no hidden state, nothing to serialise, and no request is made — the
builder's whole job is to produce the shape the server understands, and you assign it to filters.
What makes it worth using over hand-written objects is how the combinators treat operands. and, or and
not ignore falsy operands and collapse trivial cases:
That is what lets UI state go straight into the builder. A checkbox that is off, a date range that was never
picked, an empty selection — all contribute false or undefined, and the result is exactly the filter that
the set conditions describe. With no condition at all, filters is undefined, which is a query with no
filter rather than an empty node the server has to interpret.
not is the exception: it always keeps its node, even with a single operand, because unwrapping a negation
would invert its meaning.
Try it
Edit the code and watch the object change. The collapsing rules are easier to trust once you have watched
filter.and(false, null, undefined) become undefined.
{
"operator": "and",
"filters": [
{
"field": "size",
"operator": "gt",
"value": 1000
},
{
"operator": "or",
"filters": [
{
"field": "treepath",
"operator": "eq",
"value": "/HR/*"
},
{
"field": "authors",
"operator": "eq",
"value": "alice"
}
]
}
]
}The filter builder is in scope. The last expression is shown; for several lines, end with return …. Everything runs in your browser — no network call, and nothing is sent anywhere.
Recipes
Combine several facets
Each facet contributes independently, and in handles multiple values of the same column better than
several eq under an or.
import { filter } from "@sinequa/atomic";
import type { Filter } from "@sinequa/atomic";
/** Whatever your facet UI holds. Every field is optional by nature. */
export type FacetState = {
formats?: string[];
authors?: string[];
minSize?: number;
modifiedFrom?: string;
modifiedTo?: string;
};
export function buildFilters(state: FacetState): Filter | undefined {
return filter.and(
// `> 0`, not just `.length`: a length is a number, and `0 && f` is `0`, which
// is falsy but not a filter — the compiler rejects it.
(state.formats?.length ?? 0) > 0 && filter.in("docformat", state.formats ?? []),
(state.authors?.length ?? 0) > 0 && filter.in("authors", state.authors ?? []),
state.minSize !== undefined && filter.gte("size", state.minSize),
state.modifiedFrom !== undefined &&
state.modifiedTo !== undefined &&
filter.between("modified", state.modifiedFrom, state.modifiedTo),
);
}import { computed, signal } from "@angular/core";
import { filter } from "@sinequa/atomic";
export class FacetState {
readonly formats = signal<string[]>([]);
readonly authors = signal<string[]>([]);
readonly minSize = signal<number | undefined>(undefined);
/** Recomputed whenever any facet changes; feed it straight to `filters`. */
readonly filters = computed(() =>
filter.and(
// `> 0`, not just `.length`: a length is a number, and `0 && f` is `0`,
// which is falsy but not a filter — the compiler rejects it.
this.formats().length > 0 && filter.in("docformat", this.formats()),
this.authors().length > 0 && filter.in("authors", this.authors()),
this.minSize() !== undefined && filter.gte("size", this.minSize()!),
),
);
toggle(list: ReturnType<typeof signal<string[]>>, value: string) {
const current = list();
list.set(current.includes(value) ? current.filter((v) => v !== value) : [...current, value]);
}
}import { filter } from "@sinequa/atomic";
import type { Filter } from "@sinequa/atomic";
import { useMemo, useState } from "react";
export function useFacetFilters() {
const [formats, setFormats] = useState<string[]>([]);
const [authors, setAuthors] = useState<string[]>([]);
const [minSize, setMinSize] = useState<number>();
const filters: Filter | undefined = useMemo(
() =>
filter.and(
// `> 0`, not just `.length`: a length is a number, and `0 && f` is `0`,
// which is falsy but not a filter — the compiler rejects it.
formats.length > 0 && filter.in("docformat", formats),
authors.length > 0 && filter.in("authors", authors),
minSize !== undefined && filter.gte("size", minSize),
),
[formats, authors, minSize],
);
const toggle = (list: string[], set: (next: string[]) => void, value: string) =>
set(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
return { filters, formats, setFormats, authors, setAuthors, minSize, setMinSize, toggle };
}Filter on a date or size bucket
A facet like "Dates" or "Size" is a distribution: its items are ranges, not values. Their value is an
expression the server composed, so it needs filter.distribution rather than filter.eq.
import { filter } from "@sinequa/atomic";
import type { Aggregation, AggregationItem, Filter } from "@sinequa/atomic";
/**
* The filter for one clicked facet item, whatever kind of facet it came from.
*
* A distribution item's `value` looks like:
* modified`Since yesterday[fr]Depuis hier`:>= 2026-08-02
* Sending that as a value earns a 500; `distribution` parses it into comparisons.
*/
export function filterFromItem(
aggregation: Aggregation,
item: AggregationItem,
): Filter | undefined {
if (aggregation.isDistribution) {
const parsed = filter.distribution(aggregation.column, item.value);
if (parsed) return parsed;
// Not a bucket after all — fall through to the plain comparison.
}
return item.value === null ? filter.isNull(aggregation.column) : filter.eq(aggregation.column, item.value);
}import { filter } from "@sinequa/atomic";
import type { Aggregation, AggregationItem, Filter } from "@sinequa/atomic";
export function filterFromItem(
aggregation: Aggregation,
item: AggregationItem,
): Filter | undefined {
// Buckets carry an expression, not a value: parse it or the backend answers 500.
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);
}import { filter } from "@sinequa/atomic";
import type { Aggregation, AggregationItem, Filter } from "@sinequa/atomic";
export function filterFromItem(
aggregation: Aggregation,
item: AggregationItem,
): Filter | undefined {
// Buckets carry an expression, not a value: parse it or the backend answers 500.
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);
}Migrate filters stored in the old shape
State saved by an older application — a stored query, a serialised facet selection — is in the LegacyFilter
shape. legacyFilterToFilter converts it, so you migrate the data without reimplementing the mapping.
import { filter, legacyFilterToFilter } from "@sinequa/atomic";
import type { Filter, LegacyFilter } from "@sinequa/atomic";
/** Several stored filters were an implicit AND; keep that meaning. */
export function migrate(stored: LegacyFilter[]): Filter | undefined {
return filter.and(...stored.map((legacy) => legacyFilterToFilter(legacy)));
}Converting to Filter is for the query you send — it drops name/display, which the migrated shape has no
room for. To highlight the right facet items back from that same stored state, flatten the LegacyFilter itself
with flattenLegacyFilterValues instead of the converted Filter; see
Restoring selection after a reload.
Operators
Prop
Type