docs/plans/5.4-search-provider.md
Status: In progress Target: Spree 5.4 (provider + DB provider + PgSearch removal + add_search_scope removal + Meilisearch provider), 6.0 (metafield faceting) Depends on: None for 5.4. ProductType (6.0-product-types.md) for metafield faceting in 6.0. Author: Damian + Claude Last updated: 2026-03-18
Add a pluggable search provider interface (Spree::SearchProvider::Base) that abstracts product search, filtering, sorting, and facet aggregation behind a clean contract. The default implementation (Spree::SearchProvider::Database) uses standard SQL/ILIKE scopes. Meilisearch is the recommended external provider, using meilisearch-ruby (raw client, no model overrides).
This plan also:
pg_search dependency (legacy, replaced by the provider interface)add_search_scope macro (legacy, replaced by standard Rails scopes)No search abstraction. Search logic is spread across four layers — model scopes (ProductScopes), Ransack integration (ResourceController), a finder service (Products::Find), and a filter aggregator (FiltersAggregator). Replacing any one part means understanding and patching all four.
SQL search is inadequate for enterprise. The search scope uses ILIKE (or PgSearch trigram when installed). No typo tolerance, synonyms, stemming, or relevance tuning. Every serious deployment replaces this, but there's no clean seam to do so.
PgSearch is a legacy dependency. The if defined?(PgSearch) conditional in Product creates two code paths for search — one with PgSearch, one without. PgSearch only works with PostgreSQL, adds complexity for marginal benefit over plain ILIKE, and doesn't solve the real problems (relevance, typo tolerance, faceting). A proper search engine (Meilisearch) solves all of these. PgSearch should be removed entirely, not maintained alongside a provider interface.
add_search_scope is a non-standard macro. ProductScopes defines ~25 scopes using add_search_scope which uses singleton_class.send(:define_method, ...) instead of standard Rails scope. This is legacy cruft — there's no benefit over standard scopes, and it creates confusion about which scopes are "search scopes" vs regular scopes. All should be converted to standard scope definitions.
Faceted filtering is OptionType-only. FiltersAggregator builds facets from OptionType.filterable + OptionValue counts. Metafield-based attributes (material, brand, weight class) can't participate in faceted navigation, despite MetafieldDefinitions being the structured attribute system.
No autocomplete/typeahead endpoint. Search-as-you-type is expected by enterprise buyers. No endpoint exists.
Filter counts are naive. FiltersAggregator counts products per option value against the full scope, not adjusted for already-applied filters. This means selecting "Size: M" doesn't update the count on "Color: Red" to reflect only products that are both M and Red.
| What exists | Where | What it does |
|---|---|---|
search scope | Product model | ILIKE on name/SKU (or PgSearch trigram when gem installed — being removed) |
search_by_name scope | Product model (via PgSearch) | Trigram + word_similarity — being removed |
add_search_scope macro | ProductScopes concern | Legacy macro for defining scopes via singleton_class.define_method — being removed |
pg_search gem | Optional dependency | PostgreSQL-only trigram search — being removed |
Products::Find | core/app/finders/ | Orchestrates complex filter chains (IDs, SKUs, price, taxons, options, tags, stock) |
Products::Sort | core/app/finders/ | Sort by name, price, newest, best-selling |
FiltersAggregator | api/app/services/ | Builds filter metadata: price range, availability, option facets, category facets |
/products/filters | Store API endpoint | Returns available filters + counts for a product scope |
ProductScopes | core/app/models/concerns/ | ~30 scopes for filtering, sorting, stock, price, options, categories |
ResourceController | API base controller | Ransack param transformation, pagination, HTTP caching |
Spree::Dependencies.products_finder | Dependencies | Swappable finder service (already exists) |
Spree::Dependencies.products_sorter | Dependencies | Swappable sorter service (already exists) |
The provider handles everything that a search engine does better than SQL:
AR scopes handle what must be enforced at the database level:
current_store.products.active(currency) for Store API (not applied for Admin).accessible_by(current_ability, :show)The controller builds a base AR scope for security/visibility, then hands everything else to the provider:
# Store API controller
base_scope = current_store.products.active(currency).accessible_by(current_ability, :show)
result = search_provider.search_and_filter(
scope: base_scope,
query: params.dig(:q, :search),
filters: params[:q], # price_gte, with_option_value_ids, etc.
sort: params[:sort],
page: params[:page],
limit: params[:limit]
)
result.products # → AR relation (filtered, sorted, paginated)
result.filters # → facets with counts
result.total_count # → total matches
The Database provider implements this by chaining Ransack scopes + FiltersAggregator (same as today). The Meilisearch provider does search + filter + facets in one Meilisearch API call, then intersects with the base AR scope for security.
Store API Controller
│
├─ base_scope = store.products.active.accessible_by(ability) ← AR (security)
│
└─ search_provider.search_and_filter(scope: base_scope, ...) ← Provider (search + filter + facets)
│
├─ Database: scope.search(query).ransack(filters) + FiltersAggregator
│
└─ Meilisearch: meilisearch_client.search(query, filters, facets)
→ Product.where(id: result_ids).merge(base_scope) ← intersect for security
Product.search stays as a plain ILIKE scopeProduct.search(query) is a simple SQL scope. ILIKE on name + SKU. It knows nothing about providers.meilisearch-ruby client.meilisearch-ruby (raw HTTP client), not meilisearch-rails (ActiveRecord override). The model stays clean.Spree.search_provider = 'Spree::SearchProvider::Database' # default
Spree.search_provider = 'SpreeMeilisearch::SearchProvider' # with meilisearch
Consistent with existing products_finder, products_sorter pattern.
filterable flagfilterable boolean to MetafieldDefinition (default: false).single_line_text and number types where values repeat across products. Rich text and JSON types are not filterable.type: 'metafield'.pg_search entirely. No conditional if defined?(PgSearch) branches. The search scope on Product uses plain ILIKE (the current fallback path).search_by_name (trigram) removed. ILIKE on name is sufficient for the Database provider.search_by_sku, search_by_sku_or_options replaced by ILIKE equivalents.pg_search from gemspec. Currently an optional dependency. No migration needed — it's a gem removal.spree_search_meilisearch. This gives them far more than PgSearch ever did.add_search_scope macroadd_search_scope calls to standard scope definitions. There are ~25 scopes in ProductScopes using this macro. All become standard Rails scopes.add_search_scope method, search_scopes class variable, and related infrastructure from ProductScopes.whitelisted_ransackable_scopes stays as-is. Standard scopes work with Ransack identically.spree_search_meilisearch as an official Spree gem. Uses meilisearch-ruby (raw client), NOT meilisearch-rails (no model-level overrides)..active scope to the base AR scope before passing to the provider. Admin passes all products. The provider searches the full index, AR scope intersects for visibility.FiltersAggregator with Meilisearch's native capabilities. Adjusted facet counts come for free.module Spree
module SearchProvider
class Base
attr_reader :store
def initialize(store)
@store = store
end
# Search, filter, and return facets in one call.
#
# @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
# @param query [String, nil] text search query
# @param filters [Hash] structured filters (price_gte, with_option_value_ids, in_category, in_categories, etc.)
# @param sort [String, nil] sort param (e.g. 'price', '-price', 'best_selling')
# @param page [Integer] page number
# @param limit [Integer] results per page
# @return [SearchResult] { products: AR::Relation, filters: Array, total_count: Integer }
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
raise NotImplementedError
end
# Index a product — called after product save. No-op for database provider.
#
# @param product [Spree::Product] the product to index
def index(product)
# no-op by default
end
# Remove a product from the index.
#
# @param product [Spree::Product] the product to remove
def remove(product)
# no-op by default
end
# Bulk reindex — full catalog sync. Called manually or via rake task.
#
# @param scope [ActiveRecord::Relation] products to reindex (default: all in store)
def reindex(scope = nil)
# no-op by default
end
end
# Returned by search_and_filter
SearchResult = Struct.new(:products, :filters, :sort_options, :total_count, keyword_init: true)
end
end
Wraps existing Ransack + SQL scopes + FiltersAggregator. Behavior identical to today.
module Spree
module SearchProvider
class Database < Base
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
# 1. Text search via ILIKE scope
scope = scope.search(query) if query.present?
# 2. Structured filtering via Ransack
scope = scope.ransack(sanitize_filters(filters)).result(distinct: true) if filters.present?
# 3. Sorting
scope = apply_sort(scope, sort) if sort.present?
# 4. Facets via FiltersAggregator + metafield facets
filter_facets = build_facets(scope)
# 5. Pagination
total = scope.count
products = scope.offset((page - 1) * limit).limit(limit)
SearchResult.new(
products: products,
filters: filter_facets,
sort_options: available_sort_options,
total_count: total
)
end
private
def build_facets(scope)
aggregator = Spree::Api::V3::FiltersAggregator.new(
scope: scope,
store: store,
currency: Spree::Current.currency
)
result = aggregator.call
result[:filters].concat(build_metafield_facets(scope))
result[:filters]
end
def build_metafield_facets(scope)
definitions = Spree::MetafieldDefinition
.where(resource_type: 'Spree::Product', filterable: true)
.order(:sort_order)
product_ids = scope.select(:id)
definitions.filter_map do |definition|
values_with_counts = Spree::Metafield
.where(resource_type: 'Spree::Product', resource_id: product_ids)
.where(namespace: definition.namespace, key: definition.key)
.group(:value)
.count
next if values_with_counts.empty?
{
id: definition.prefixed_id,
type: 'metafield',
name: definition.key,
namespace: definition.namespace,
presentation: definition.name,
options: values_with_counts.map do |value, count|
{ value: value, count: count }
end.sort_by { |o| -o[:count] }
}
end
end
def sanitize_filters(filters)
# Strip text search param — already handled by scope.search
filters.except(:search)
end
def apply_sort(scope, sort)
# Delegate to existing sort logic (Ransack s param or custom scopes)
scope
end
def available_sort_options
%w[price -price best_selling name -name -available_on available_on]
end
end
end
end
spree_search_meilisearch gem)Uses meilisearch-ruby (raw HTTP client). No model-level overrides.
module SpreeMeilisearch
class SearchProvider < Spree::SearchProvider::Base
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
# One Meilisearch call: search + filter + facets
ms_result = client.index(index_name).search(query || '', {
filter: build_meilisearch_filters(filters),
facets: facet_attributes,
sort: build_meilisearch_sort(sort),
offset: (page - 1) * limit,
limit: limit
})
ids = ms_result['hits'].map { |h| h['id'] }
# Intersect with AR scope for security/visibility
products = scope.where(id: ids).in_order_of(:id, ids)
SearchResult.new(
products: products,
filters: build_filters_from_facet_distribution(ms_result['facetDistribution']),
sort_options: available_sort_options,
total_count: ms_result['estimatedTotalHits']
)
end
def index(product)
client.index(index_name).add_documents([serialize(product)])
end
def remove(product)
client.index(index_name).delete_document(product.id)
end
def reindex(scope = nil)
scope ||= store.products
scope.find_in_batches(batch_size: 500) do |batch|
client.index(index_name).add_documents(batch.map { |p| serialize(p) })
end
end
private
def client
@client ||= MeiliSearch::Client.new(
ENV['MEILISEARCH_URL'] || 'http://localhost:7700',
ENV['MEILISEARCH_API_KEY']
)
end
def index_name
"#{store.code}_products"
end
def serialize(product)
{
id: product.id,
prefixed_id: product.prefixed_id,
name: product.name,
description: product.description,
slug: product.slug,
status: product.status,
price: product.price_in(store.default_currency)&.amount&.to_f,
currency: store.default_currency,
category_ids: product.taxon_ids,
option_values: product.variants.flat_map { |v| v.option_values.map(&:presentation) },
sku: product.sku,
in_stock: product.in_stock?,
primary_media_url: product.primary_media&.url(:large),
created_at: product.created_at.to_i,
available_on: product.available_on&.to_i,
# Filterable metafields
**filterable_metafields(product)
}
end
def filterable_metafields(product)
Spree::MetafieldDefinition.where(resource_type: 'Spree::Product', filterable: true).each_with_object({}) do |defn, hash|
metafield = product.metafields.detect { |m| m.namespace == defn.namespace && m.key == defn.key }
hash["mf_#{defn.namespace}_#{defn.key}"] = metafield&.value
end
end
def facet_attributes
# Option types + filterable metafields + price + stock
['price', 'in_stock', 'category_ids'] +
Spree::OptionType.filterable.pluck(:name).map { |n| "option_#{n}" } +
Spree::MetafieldDefinition.where(resource_type: 'Spree::Product', filterable: true)
.pluck(:namespace, :key).map { |ns, k| "mf_#{ns}_#{k}" }
end
end
end
The Store API products controller delegates to the provider for all search + filtering:
module Spree::Api::V3::Store
class ProductsController < ResourceController
private
def collection
base_scope = scope.accessible_by(current_ability, :show)
result = search_provider.search_and_filter(
scope: base_scope,
query: params.dig(:q, :search),
filters: params[:q]&.except(:search),
sort: params[:sort],
page: params[:page],
limit: params[:limit]
)
# Store filters for the response (if requested via expand=filters)
@filters = result.filters
@total_count = result.total_count
result.products
end
def search_provider
@search_provider ||= Spree.search_provider.constantize.new(current_store)
end
end
end
module Spree::Api::V3::Store
class Products::FiltersController < BaseController
def index
base_scope = current_store.products
.active(current_currency)
.accessible_by(current_ability, :show)
result = search_provider.search_and_filter(
scope: base_scope,
query: params.dig(:q, :search),
filters: params[:q]&.except(:search),
page: 1,
limit: 0 # no products needed, just facets
)
render json: {
filters: result.filters,
sort_options: result.sort_options,
total_count: result.total_count
}
end
private
def search_provider
@search_provider ||= Spree.search_provider.constantize.new(current_store)
end
end
end
module Spree
module SearchIndexable
extend ActiveSupport::Concern
included do
after_commit :index_in_search, on: [:create, :update]
after_commit :remove_from_search, on: :destroy
end
private
def index_in_search
return unless stores.any?
stores.each do |store|
provider = Spree.search_provider.constantize.new(store)
provider.index(self)
end
end
def remove_from_search
stores.each do |store|
provider = Spree.search_provider.constantize.new(store)
provider.remove(self)
end
end
end
end
# In Spree::Product
class Spree::Product < Spree.base_class
include Spree::SearchIndexable
end
For the Database provider, index and remove are no-ops. For external providers, these trigger async indexing (via a job wrapper if needed).
# rake spree:search:reindex
namespace :spree do
namespace :search do
task reindex: :environment do
Spree::Store.all.find_each do |store|
provider = Spree.search_provider.constantize.new(store)
puts "Reindexing #{store.name} (#{store.products.count} products)..."
provider.reindex(store.products)
puts "Done."
end
end
end
end
class Spree::MetafieldDefinition < Spree.base_class
# New column
attribute :filterable, :boolean, default: false
scope :filterable, -> { where(filterable: true) }
end
The filter response shape stays the same, with a new type: 'metafield' filter type:
{
"filters": [
{ "id": "price", "type": "price_range", "min": 10, "max": 100, "currency": "USD" },
{ "id": "availability", "type": "availability", "options": [...] },
{ "id": "opt_abc", "type": "option", "name": "size", "presentation": "Size", "options": [...] },
{
"id": "mfdef_xyz",
"type": "metafield",
"name": "material",
"namespace": "product",
"presentation": "Material",
"options": [
{ "value": "Cotton", "count": 42 },
{ "value": "Polyester", "count": 18 }
]
}
],
"sort_options": [...],
"total_count": 60
}
New Ransack scope on Product:
# In Product model
scope :with_metafield, ->(namespace, key, value) {
joins(:metafields)
.where(spree_metafields: { namespace: namespace, key: key, value: value })
}
API usage: q[with_metafield][]=product|material|Cotton
interface MetafieldFilter {
id: string // prefixed MetafieldDefinition ID
type: 'metafield'
name: string // key
namespace: string
presentation: string // MetafieldDefinition.name
options: Array<{
value: string
count: number
}>
}
// ProductFilter union gains MetafieldFilter
type ProductFilter = PriceRangeFilter | AvailabilityFilter | OptionFilter | CategoryFilter | MetafieldFilter
add_search_scope macroadd_search_scope calls in ProductScopes to standard scope definitionsadd_search_scope method, search_scopes class variable, and self.search_scopes accessorwhitelisted_ransackable_scopes if needed (should be identical)if defined?(PgSearch) conditionals in Product and Variantpg_search_scope declarations: search_by_name, search_by_sku, search_by_sku_or_optionspg_search from spree_core.gemspec optional dependenciesTranslation.search_by_name)Spree::SearchProvider::Base and Spree::SearchProvider::Databasesearch_provider in Spree::Dependenciessearch scope, FiltersAggregator, and ILIKE autocompletesearch_provider.search() into ProductsController for q[search] paramsearch_provider.aggregate() into FiltersControllerSearchIndexable concern to Product (no-op for database provider)rake spree:search:reindex taskspree_search_meilisearch gem (official, maintained by Spree team)meilisearch-ruby gem (raw client, no model-level overrides)SearchIndexable concern on Product triggers async indexingrake spree:search:reindex delegates to Meilisearch bulk indexingclass AddFilterableToMetafieldDefinitions < ActiveRecord::Migration[7.2]
def change
add_column :spree_metafield_definitions, :filterable, :boolean, null: false, default: false
add_index :spree_metafield_definitions, :filterable
end
end
filterable column and scopewith_metafield Ransack scope to Product (for Database provider)add_search_scope calls. Use standard scope definitions. The macro is being removed.filterable columns on other models.Admin search. Same provider powers both Store API and Admin API. Meilisearch indexes all products (all statuses). Store API applies .active scope to filter results. Admin has no such scope — gets full search across drafts, archived, paused. No separate admin provider needed.
PgSearch vs Meilisearch. Drop PgSearch entirely. It's PostgreSQL-only, adds conditional code paths, and provides marginal benefit over ILIKE. Meilisearch is the recommended upgrade for serious search needs — typo tolerance, stemming, synonyms, faceting, and relevance tuning. The Database provider uses plain ILIKE as the baseline. We use meilisearch-ruby (raw client), not meilisearch-rails, to avoid model-level method overrides and own the integration fully.
add_search_scope removal. Convert to standard Rails scopes. The macro adds no value — it was created before Rails scopes were as capable as they are today. Standard scopes work identically with Ransack.
Per-store providers. Should different stores in the same Spree instance use different search providers? (e.g., small store uses Database, large store uses Meilisearch). If yes, provider config moves to Store model instead of global Dependencies. Leaning toward global for 6.0, per-store for 6.1.
Cross-model search. Enterprise buyers want unified search across products, categories, pages, and blog posts. Should the provider interface support multiple resource types, or is product search the only contract? Leaning toward product-only for 6.0.
Filter count adjustment. Should the Database provider attempt cross-filter count adjustment (selecting "Size: M" updates "Color: Red" count)? This requires N+1 subqueries in SQL. Meilisearch handles this natively via facetDistribution. Leaning toward naive counts for Database provider, adjusted counts for Meilisearch.
spree/core/app/models/concerns/spree/product_scopes.rb (has add_search_scope macro — being removed)spree/core/app/finders/spree/products/find.rbspree/api/app/services/spree/api/v3/filters_aggregator.rbspree/api/app/controllers/spree/api/v3/store/products/filters_controller.rbspree/api/app/controllers/spree/api/v3/store/products_controller.rbspree/core/lib/spree/core/dependencies.rbspree/core/app/models/spree/product.rb (has PgSearch conditional — being removed)spree/core/app/models/spree/variant.rb (has PgSearch scopes — being removed)spree/core/app/models/spree/metafield_definition.rbmulti_search → search shipped in 5.4 (backward compat alias kept until 6.0)6.0-product-types.md (MetafieldDefinition schema enforcement)6.0-channels-catalogs-b2b.md (Catalog-scoped product visibility)