Class: Spree::SearchProvider::Meilisearch
- Defined in:
- app/models/spree/search_provider/meilisearch.rb
Constant Summary collapse
- PREFIXED_ID_PATTERN =
/\A[a-z]+_[A-Za-z0-9]+\z/- ALLOWED_STATUSES =
%w[active draft archived paused].freeze
Instance Attribute Summary
Attributes inherited from Base
Class Method Summary collapse
Instance Method Summary collapse
-
#ensure_index_settings! ⇒ Object
Configure index settings for filtering, sorting, and faceting.
-
#ensure_index_settings_once! ⇒ Object
Lightweight guard — configures index settings once per provider instance.
- #index(product) ⇒ Object
- #index_batch(documents) ⇒ Object
-
#initialize(store) ⇒ Meilisearch
constructor
A new instance of Meilisearch.
- #reindex(scope = nil) ⇒ Object
- #remove(product) ⇒ Object
-
#remove_by_id(prefixed_id) ⇒ Object
Remove all documents for a product by its prefixed_id (e.g. ‘prod_abc’).
- #search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25) ⇒ Object
Constructor Details
#initialize(store) ⇒ Meilisearch
Returns a new instance of Meilisearch.
13 14 15 16 17 18 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 13 def initialize(store) super require 'meilisearch' rescue LoadError raise LoadError, "Add `gem 'meilisearch'` to your Gemfile to use the Meilisearch search provider" end |
Class Method Details
.indexing_required? ⇒ Boolean
9 10 11 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 9 def self.indexing_required? true end |
Instance Method Details
#ensure_index_settings! ⇒ Object
Configure index settings for filtering, sorting, and faceting. Called automatically by reindex, but can be called separately. Waits for all settings tasks to complete before returning so that subsequent add_documents calls use the correct filterable/sortable attributes.
175 176 177 178 179 180 181 182 183 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 175 def ensure_index_settings! index = client.index(index_name) tasks = [] tasks << index.update_filterable_attributes(filterable_attributes) tasks << index.update_sortable_attributes(sortable_attributes) tasks << index.update_searchable_attributes(searchable_attributes) tasks.each { |task| task&.await } @index_settings_configured = true end |
#ensure_index_settings_once! ⇒ Object
Lightweight guard — configures index settings once per provider instance. Meilisearch settings updates are idempotent, so repeated calls are safe but we avoid the overhead by memoizing.
188 189 190 191 192 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 188 def ensure_index_settings_once! return if @index_settings_configured ensure_index_settings! end |
#index(product) ⇒ Object
126 127 128 129 130 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 126 def index(product) ensure_index_settings_once! documents = presenter_class.new(product, store).call client.index(index_name).add_documents(documents, 'id') end |
#index_batch(documents) ⇒ Object
136 137 138 139 140 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 136 def index_batch(documents) return if documents.empty? client.index(index_name).add_documents(documents, 'id') end |
#reindex(scope = nil) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 150 def reindex(scope = nil) scope ||= store.products ensure_index_settings! indexed = 0 scope.reorder(id: :asc) .preload_associations_lazily .find_in_batches(batch_size: 500) do |batch| documents = batch.flat_map { |product| presenter_class.new(product, store).call } next if documents.empty? index_batch(documents) indexed += documents.size Rails.logger.info { "[Meilisearch] Enqueued #{documents.size} documents (#{indexed} total) for #{index_name}" } end Rails.logger.info { "[Meilisearch] Reindex complete: #{indexed} documents enqueued for #{index_name}" } indexed end |
#remove(product) ⇒ Object
132 133 134 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 132 def remove(product) remove_by_id(product.prefixed_id) end |
#remove_by_id(prefixed_id) ⇒ Object
Remove all documents for a product by its prefixed_id (e.g. ‘prod_abc’)
143 144 145 146 147 148 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 143 def remove_by_id(prefixed_id) filter = "product_id = '#{sanitize_prefixed_id(prefixed_id)}'" client.index(index_name).delete_documents(filter: filter) rescue ::Meilisearch::ApiError => e raise unless e.http_code == 404 end |
#search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25) ⇒ Object
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'app/models/spree/search_provider/meilisearch.rb', line 20 def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25) page = [page.to_i, 1].max limit = limit.to_i.clamp(1, 100) filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h) filters = (filters || {}).stringify_keys # Extract and group option values by option type for proper OR/AND semantics option_value_ids = extract_and_delete(filters, 'with_option_value_ids') = group_option_values_by_type(Array(option_value_ids)) base_conditions = build_filters(filters) option_conditions = build_grouped_option_conditions() all_conditions = base_conditions + option_conditions search_params = { filter: all_conditions, facets: facet_attributes, sort: build_sort(sort), offset: (page - 1) * limit, limit: limit } Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" } begin if .any? # N+1 multi-search: 1 hit query + 1 disjunctive facet query per active option type queries = [{ indexUid: index_name, q: query.to_s, **search_params }] option_type_ids_ordered = .keys option_type_ids_ordered.each do |option_type_id| without_this = build_grouped_option_conditions(.except(option_type_id)) queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], limit: 0 } end results = client.multi_search(queries) ms_result = results['results'][0] # Merge disjunctive counts per option type. # Each disjunctive query excluded one option type's filter. # Use that query's full option_value_ids distribution for that option type's values, # and the main query's distribution for everything else. main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {} # Build a set of prefixed IDs per option type (including unselected values) # by looking up which option type each option value belongs to. all_ov_prefixed_ids = Set.new disjunctive_dists = {} results['results'][1..].each_with_index do |r, idx| dist = r.dig('facetDistribution', 'option_value_ids') || {} disjunctive_dists[option_type_ids_ordered[idx]] = dist all_ov_prefixed_ids.merge(dist.keys) end # Resolve which prefixed IDs belong to which option type all_raw_ids = all_ov_prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) } ov_to_type = Spree::OptionValue.where(id: all_raw_ids).pluck(:id, :option_type_id).to_h prefixed_to_type = all_ov_prefixed_ids.each_with_object({}) do |pid, h| raw = Spree::OptionValue.decode_prefixed_id(pid) h[pid] = ov_to_type[raw] if raw end # Start with main query's distribution, overlay disjunctive counts for active option types merged_ov_dist = main_ov_dist.dup disjunctive_dists.each do |option_type_id, dist| dist.each do |pid, count| merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id end end facet_distribution = (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist) else ms_result = client.index(index_name).search(query.to_s, search_params) facet_distribution = ms_result['facetDistribution'] || {} end rescue ::Meilisearch::ApiError => e Rails.logger.warn { "[Meilisearch] Search failed: #{e.}. Run `rake spree:search:reindex` to initialize the index." } Rails.error.report(e, handled: true, context: { index: index_name, query: query }) return empty_result(scope, page, limit) end Rails.logger.debug { "[Meilisearch] #{ms_result['estimatedTotalHits']} hits in #{ms_result['processingTimeMs']}ms" } # Hits have composite prefixed_id (prod_abc_en_USD), extract product_id (prod_abc) product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) } # Intersect with AR scope for security/visibility, preserving Meilisearch sort order. products = if raw_ids.any? records = scope.where(id: raw_ids).reorder(nil).index_by(&:id) raw_ids.filter_map { |id| records[id] } else scope.none end pagy = build_pagy(ms_result, page, limit) SearchResult.new( products: products, filters: build_facet_response(facet_distribution), sort_options: .map { |id| { id: id } }, default_sort: 'manual', total_count: ms_result['estimatedTotalHits'] || 0, pagy: pagy ) end |