Back to Spree

Disjunctive Option Value Faceting

docs/plans/5.4-disjunctive-option-faceting.md

5.4.24.6 KB
Original Source

Disjunctive Option Value Faceting

Status: In Progress Target: Spree 5.4 Depends on: 5.4-search-provider.md (SearchProvider interface) Author: Damian Last updated: 2026-03-26

Summary

PLP option filters use a flat with_option_value_ids array. Selecting "Blue" under Color shows Light Blue count as 1 instead of 18 because facet counts are computed against the already-filtered set. The fix: the backend groups selected values by option type (one query) and computes disjunctive counts per option type. No API, SDK, or storefront changes needed.

Key Decisions (do not deviate without discussion)

  • No API changewith_option_value_ids stays as a flat array. The backend groups values by option type server-side via a single OptionValue.where(id:).group_by(&:option_type_id) query.
  • N+1 multi-search in Meilisearch — 1 hit query + 1 facet query per active option type (excluding that type's values). Single HTTP call via /multi-search.
  • Database FiltersAggregator — receives grouped option values, computes counts per option type against a scope excluding that type's filter.

Design Details

Server-Side Grouping

When with_option_value_ids arrives, the search provider groups values by option type:

ruby
def group_option_values_by_type(prefixed_ids)
  return {} if prefixed_ids.blank?

  raw_ids = prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) }
  Spree::OptionValue.where(id: raw_ids).group_by(&:option_type_id)
end

This produces { option_type_id_1 => [ov_a, ov_b], option_type_id_2 => [ov_c] } — enough to build per-type filter conditions and disjunctive facet queries.

Meilisearch Provider

ruby
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
  option_value_ids = extract_option_value_ids(filters)
  grouped = group_option_values_by_type(option_value_ids)

  # Main query: all filters (OR within type, AND across types)
  all_conditions = build_filters(filters) + build_grouped_option_conditions(grouped)

  queries = [{ indexUid: index_name, q: query.to_s, filter: all_conditions, facets: facet_attributes, ... }]

  # Disjunctive facet query per active option type
  grouped.each_key do |option_type_id|
    without_this_type = build_grouped_option_conditions(grouped.except(option_type_id))
    queries << { indexUid: index_name, q: query.to_s, filter: build_filters(filters) + without_this_type, facets: ['option_value_ids'], limit: 0 }
  end

  results = client.multi_search(queries)
  # Merge: option_value_ids facet distribution from disjunctive queries
end

Database Provider + FiltersAggregator

The Database provider extracts with_option_value_ids before Ransack and applies per-type scoping:

ruby
def apply_option_filters(scope, filters)
  ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
  return [scope, {}] if ids.blank?

  grouped = group_option_values_by_type(Array(ids))
  grouped.each_value do |ovs|
    scope = scope.with_option_value_ids(ovs.map(&:id))
  end

  [scope, grouped]
end

FiltersAggregator receives the grouped hash and computes disjunctive counts:

ruby
def option_value_data(option_type, option_value)
  # Use scope excluding THIS option type's selected values
  scope = disjunctive_scope_for(option_type)
  scope.joins(:option_value_variants)
       .where(option_value_variants: { option_value_id: option_value.id })
       .distinct.count
end

What Stays The Same

  • API params: q[with_option_value_ids][]=optval_blue&q[with_option_value_ids][]=optval_small
  • SDK ProductListParams.with_option_value_ids
  • Storefront ActiveFilters.optionValues: string[]
  • Filter response shape

Migration Path

  1. Meilisearch provider: intercept with_option_value_ids, group by type, N+1 multi-search
  2. Database provider: intercept with_option_value_ids, group by type, pass to FiltersAggregator
  3. FiltersAggregator: accept grouped option values, compute disjunctive counts per type
  4. Done — no SDK/storefront/API changes

Constraints on Current Work

  • with_option_value_ids Ransack scope must keep working for direct Ransack usage
  • Meilisearch multi-search fallback: if only 1 option type is active, skip multi-search (single query suffices for facet counts of other option types)

Open Questions

None.

References