Skip to content

Support exists / notExists filter operators for set-membership filtering #11952

Description

@john9806

Is your feature request related to a problem? Please describe.

Right now there's no clean way to filter one cube based on whether a related
row exists in another cube, without going through a measure.

Example: I want revenue for all customers who opened a support ticket in the
last 30 days.

SELECT SUM(amount) FROM revenue
WHERE customer_id IN (
  SELECT customer_id FROM tickets WHERE created_at > current_date - 30
)

The closest thing Cube has is subquery dimensions (sub_query: true), but
those require the subquery to resolve to a measure/aggregation. So to check
"does this customer have a ticket" you end up defining a ticket_count
measure on the tickets cube and filtering ticket_count > 0, just to fake
an existence check. That's a measure that doesn't mean anything on its own,
it only exists to be compared to zero.

It also means every "does X have a related Y" question requires a
predefined dimension in the model ahead of time. There's no way to do this
ad hoc at query time the way you could with a normal filter.

Describe the solution you'd like

A new pair of filter operators, exists and notExists, where the filter
value is a nested query instead of a list of values:

{
  "member": "customers.id",
  "operator": "exists",
  "query": {
    "dimensions": ["tickets.customer_id"],
    "filters": [
      { "member": "tickets.created_at", "operator": "inDateRange", "values": ["last 30 days"] }
    ]
  }
}

This would compile to a plain WHERE customers.id IN (SELECT tickets.customer_id FROM tickets WHERE ...) (or EXISTS (...) with a correlated condition,
whichever fits the planner better), with no aggregation or GROUP BY
involved. notExists would be the negated version.

Describe alternatives you've considered

Subquery dimensions, as described above. They work but force you to model
an aggregate measure for something that isn't really an aggregation, and
they're fixed at data-model time rather than usable ad hoc in a query.

Additional context

Happy to put together a PR for this if the general direction is OK.

Activity

  1. john9806 commented on Sep 21, 2026

    @john9806
    Author

    I looked into what it would take to support this on the Tesseract (Rust) planner, since that's the default now and the legacy JS planner is on its way out. Wanted to share the approach in case it's useful, and to get a sanity check before writing actual code.

    The gap: the wire format that feeds the Rust planner (FilterItem in cube_bridge/base_query_options.rs) only carries scalar filter values — there's no slot for a nested query. So exists/notExists would need a new field there, e.g. query: Option<...>, similar in spirit to how or/and already nest FilterItem recursively.

    The part I was less sure about: can you actually plan a query-inside-a-query without breaking param handling? Turns out yes, and it's a pattern that already half-exists in the codebase:

    • BaseQueryOptions is just a trait. There's already a plain-Rust implementation of it used in tests (MockBaseQueryOptions), which proves you don't need a live JS object on the other side — a struct built from parsed JSON works fine.
    • QueryPlanner::new(properties, query_tools) doesn't care whether the properties/state it's given represent "the" top-level query or something nested — it just plans whatever it's handed.
    • The params allocator lives on QueryTools, which is shared via Rc through State. So if a nested query is planned using the same shared State as the outer query, its params land in the same allocator automatically — no manual merging needed.
    • The trickiest bit: rendering SQL text and resolving param placeholders are two separate steps (to_sql() vs build_sql_and_params()), and the latter only runs once, at the very end, on the final combined SQL string. That means a nested query can be planned and rendered to plain SQL text (with its placeholders already registered in the shared allocator), and that text can just be spliced into the outer query as a literal string — member IN () — before the single top-level param resolution pass runs. No double-resolution risk.

    So the shape of a solution would be:

    1. Add a query field to the filter wire format.
    2. Add a small production BaseQueryOptions impl for the nested query (modeled on the existing mock).
    3. Plan the nested query with the parent's shared State, render it to SQL text, and splice it into a new FilterOp::Exists/NotExists variant.
    4. Add the Exists/NotExists operator to FilterOperator (mechanical).

    One thing this first pass wouldn't handle: pre-aggregation matching for the nested subquery — it'd just always hit the base SQL. That seems like a reasonable v1 limitation to call out explicitly rather than solve upfront.

    Happy to take a stab at this if the general direction sounds right to maintainers — mostly wanted to check the approach before sinking time into it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions