Skip to content

Landing-Page Filter Groups (OR / AND)

Date: 2026-06-02

Problem / motivation

Landing-page queries (findLandingPages, findLandingPageKpis) only supported a flat list of filters that were always AND-ed together. There was no way to express alternatives such as "invoices that are DRAFT or SENT" or compound logic like (status = DRAFT OR status = SENT) AND total > 100. Users filtering reports and landing pages needed OR semantics, not just AND.

In scope

  • A new optional filterGroups argument on the landing-page queries that lets a caller pass one or more groups, each combining its own filters with AND or OR.
  • Groups are AND-ed with each other and with the existing top-level filters, enabling (a OR b) AND (c OR d) style logic (one level of nesting).
  • Full backward compatibility: the existing flat filters argument keeps its exact AND semantics; callers that don't send filterGroups are unaffected.

Out of scope

  • Arbitrary-depth nesting (a recursive group tree). Deferred — one level of grouping covers the reporting cases we have today. See Future additions.
  • Chat-agent / NL query support for groups. The conversation agent still emits flat filters only; teaching it to emit filterGroups is a fast follow.
  • Frontend UI work in cifras-admin (a separate change against this contract).

Data model changes

None. This is a pure GraphQL query-surface change — no tables, columns, or migrations.

GraphQL surface

New enum:

enum LogicalOperator {
  AND
  OR
}

New input:

input FilterGroup {
  filters: [Filter!]!
  operator: LogicalOperator! = AND
}

Changed query signatures (both gain an optional filterGroups: [FilterGroup!]):

findLandingPages(
  sourceType: LandingSourceType!
  filters: [Filter!]
  filterGroups: [FilterGroup!]
  orderBy: [OrderBy!]
  limit: Int = 10
  offset: Int = 0
  generateCsv: Boolean! = false
): GenericLandingPage!

findLandingPageKpis(
  sourceType: LandingSourceType!
  filters: [Filter!]
  filterGroups: [FilterGroup!]
): [LandingPageKpiResponse!]!

Semantics

  • Each Filter inside a FilterGroup is built into a condition exactly the way top-level filters are (same operators, same value parsing, same skip rules for blank/unparseable values).
  • The group's conditions are combined with the group operator (ORor_, ANDand_, default AND).
  • Every group is then AND-ed with the top-level filters and the other groups:
WHERE <top-level filters AND-ed>
  AND (<group 1 conditions combined with its operator>)
  AND (<group 2 conditions combined with its operator>)
  • A group whose filters all resolve to nothing (e.g. every value is blank) is skipped and adds no clause.

Example

(status = DRAFT OR status = SENT) AND total > 100:

{
  "sourceType": "INVOICES",
  "filterGroups": [
    {
      "operator": "OR",
      "filters": [
        { "columnName": "status", "operator": "EQ", "value": "DRAFT" },
        { "columnName": "status", "operator": "EQ", "value": "SENT" }
      ]
    },
    {
      "operator": "AND",
      "filters": [
        { "columnName": "total", "operator": "GT", "value": "100" }
      ]
    }
  ]
}

The two groups are AND-ed, so the second single-filter group acts as a plain top-level condition; you could equivalently pass total > 100 in the flat filters list.

What's being implemented

  • filter_types.py — added the LogicalOperator enum and the FilterGroup input.
  • filters.py — extracted build_filter_condition (single-filter → SQLAlchemy condition) out of the old loop, lifted the operation map to a module constant, and reworked apply_filters to accept filters and filter_groups.
  • search_types.py — both get_pagination_window methods now accept and forward filter_groups.
  • landing_report_service.pyfind_landing_pages and find_landing_page_kpis accept and forward filter_groups.
  • landing_queries.py — both resolvers expose the filter_groups argument.
  • tests/graphql/common/test_filters.py — unit tests for AND-only, OR group, AND group, base+group combination, empty group skip, and unknown-column handling.

Frontend impact

  • findLandingPages / findLandingPageKpis gain an optional filterGroups argument. Existing calls that only send filters continue to behave exactly as before (everything AND-ed) — no migration required.
  • To use OR logic, send filterGroups: [{ operator: OR, filters: [...] }]. Each Filter inside a group has the same shape as today (columnName, operator, value / values).
  • Groups AND with each other and with any top-level filters. To build (a OR b) AND (c OR d), send two groups, each with operator: OR.
  • Only one level of grouping is supported; there is no nested-group field yet.

Future additions

  • Recursive group tree — arbitrary nesting (react-querybuilder-style) if a use case needs ((a OR b) AND c) OR d. Deferred because one level covers current reporting needs and keeps the frontend builder simple.
  • Chat-agent support — extend GraphQLFilter / the conversation-agent prompt to emit filterGroups so natural-language queries can use OR logic. Fast follow.