docs/plans/5.4-disjunctive-option-faceting.md
Status: In Progress
Target: Spree 5.4
Depends on: 5.4-search-provider.md (SearchProvider interface)
Author: Damian
Last updated: 2026-03-26
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.
with_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./multi-search.When with_option_value_ids arrives, the search provider groups values by option type:
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.
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
The Database provider extracts with_option_value_ids before Ransack and applies per-type scoping:
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:
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
q[with_option_value_ids][]=optval_blue&q[with_option_value_ids][]=optval_smallProductListParams.with_option_value_idsActiveFilters.optionValues: string[]with_option_value_ids, group by type, N+1 multi-searchwith_option_value_ids, group by type, pass to FiltersAggregatorwith_option_value_ids Ransack scope must keep working for direct Ransack usageNone.
5.4-search-provider.md — SearchProvider interface