Tulip Logo IconTulip
Query

Filtering

Define URL-backed filters and compile resolved values into backend-agnostic condition trees.

Filters are descriptor-based. A descriptor defines how a value is parsed from the URL, validated at runtime, and mapped to a portable condition tree.

Define Filters

import { arrayFilter, defineFilters, enumFilter, stringFilter } from "@tulip-systems/query";

const projectStatuses = ["planned", "active", "done"] as const;

export const projectFilters = defineFilters({
  search: stringFilter.build((value) =>
    value
      ? {
          or: [
            { field: "title", operator: "ilike", value: `%${value}%` },
            { field: "customerName", operator: "ilike", value: `%${value}%` },
          ],
        }
      : null,
  ),
  customerId: arrayFilter(stringFilter).build((value) =>
    value?.length ? { field: "customerId", operator: "in", value } : null,
  ),
  status: arrayFilter(enumFilter(projectStatuses)).build((value) =>
    value?.length ? { field: "status", operator: "in", value } : null,
  ),
});

Return null or undefined when a filter is inactive.

Client Hook

Use useFilters(...) in client components that own dataset state.

import { useFilters } from "@tulip-systems/query/client";

const filtering = useFilters(projectFilters, {
  defaults: { status: ["active"] },
  constraints: { customerId: [customerId] },
});

The hook returns:

{
  input;
  query;
  setQuery;
  defaults;
  constraints;
}

Use input for queries. It includes defaults and constraints. Use query only when you specifically need the raw URL-backed state.

Filter Components

Resolve descriptors once and pass them to dataset filter components.

import { resolveFilterDescriptors } from "@tulip-systems/query";
import { FilterCombobox, FilterSearch } from "@tulip-systems/query/client";

const filters = resolveFilterDescriptors(projectFilters);

export function ProjectToolbar() {
  return (
    <>
      <FilterSearch placeholder="Zoeken..." filter={filters.search} />
      <FilterCombobox title="Status" filter={filters.status} options={statusOptions} />
    </>
  );
}

FilterSearch replaces table-level search inputs. It writes through the filter descriptor key instead of a hard-coded search query param.

Dependent Filter Options

When a toolbar option query depends on the currently resolved table filters, read the table context and defer the resolved filter input.

import { useSuspenseQuery } from "@tanstack/react-query";
import { useTableConfigContext } from "@tulip-systems/data-tables/client";
import { useDeferredValue } from "react";

export function ProjectCustomerFilter() {
  const table = useTableConfigContext();
  const input = useDeferredValue({ filters: table.filtering?.input ?? {} });

  const { data } = useSuspenseQuery(orpc.projects.filterCustomers.queryOptions({ input }));

  return <FilterCombobox title="Klanten" filter={filters.customerId} options={data} />;
}

Do not create a second useFilters(...) controller inside toolbar option filters. The table context already contains the resolved filtering state.

Schemas And Parsers

Use resolver helpers to create API schemas and parsers from the same filter definition.

import {
  createFilteringParser,
  resolveFiltersSchema,
  resolveParsedFilteringSchema,
} from "@tulip-systems/query";

export const projectFiltersSchema = resolveFiltersSchema(projectFilters);
export const projectParsedFiltersSchema = resolveParsedFilteringSchema(projectFilters);
export const projectFiltersParser = createFilteringParser(projectFilters);

resolveFiltersSchema(...) validates raw query input. createFilteringParser(...) converts resolved values into the portable condition tree.

Database Compilation

Compile parsed filters at the backend boundary.

import { createDatabaseFiltersCompiler } from "@tulip-systems/query/drizzle";

const parsedFilters = projectFiltersParser.parse(input.filters);
const where = createDatabaseFiltersCompiler(projectFilters)({
  title: projects.title,
  customerId: projects.customerId,
  status: projects.status,
}).parse(parsedFilters);

Filter fields are logical compiler keys. They do not have to match database column names. Use custom resolver functions when a logical field maps to joins, provider APIs, or relation-aware exists(...) clauses.

On this page