@sinequa/atomic

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

One search, one filter
src/main.ts
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));

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:

0 1 2 or more filter.and(a, false, b) drop falsy operands how many remain? undefined that operand, unwrapped "{ operator: 'and', filters: [...

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.

Examples
The Filter object
{
  "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.

Several facets at once
src/build-filters.ts
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),
  );
}

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.

Clicking a bucket in a distribution facet
src/filter-from-item.ts
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);
}

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.

src/migrate.ts
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

Pitfalls

What's next

On this page