spree_meilisearch 6.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 84e0752df6a66a59ffbf4898732d0d39ca87c9b63ada68061a6e3ed9662b6241
4
+ data.tar.gz: fb2100cf057a985acca78d3bb7766a4cf800efef35cf2005a9d0a60c369e2f17
5
+ SHA512:
6
+ metadata.gz: 96bba5d093de7a0daf404f2c79ec8a6fa5457f5507bffa4500b765f273f432934663c056581511a4ad717b798d9596136266a32aed28847544cdba64267a4b98
7
+ data.tar.gz: 6f021d3d6bd8a87f4e9c5d7ae90ffe4194a224583a33fb90a87e79b536c0c236235ea67435276add0ef3f6752fc232e510cb5642aa67e39298589c983c48a76e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present, Vendo Sp. z o.o., Vendo Connect Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # spree_meilisearch
2
+
3
+ Instant product search, filtering and faceted navigation for Spree via [Meilisearch](https://www.meilisearch.com) — an implementation of the `Spree::SearchProvider` interface.
4
+
5
+ ## What it does
6
+
7
+ - **`SpreeMeilisearch::SearchProvider`** answers product search, filtering and faceted navigation from a Meilisearch index instead of the database. One index per store. Search results are always intersected with the caller's ActiveRecord scope, so a stale index can never widen what a customer is allowed to see — it can only ever show less.
8
+ - **`SpreeMeilisearch::ProductPresenter`** builds the documents. Each product is indexed once per locale and currency it is priced in, with the fields flattened for filtering: price, stock, categories, collections, option values, tags, and any custom fields marked searchable or sortable.
9
+ - Facet counts are disjunctive: the counts shown for one option type are computed as if that type's own filters were not applied, so a customer picking "Red" still sees how many blue items are available.
10
+ - Merchant-ordered category and collection pages are supported through extra membership documents carrying the hand-set position.
11
+
12
+ Without this gem Spree searches the database directly (`Spree::SearchProvider::Database`), which needs no extra infrastructure but has no full-text ranking or typo tolerance.
13
+
14
+ ## Setup
15
+
16
+ 1. Run Meilisearch, and point the application at it:
17
+
18
+ ```bash
19
+ MEILISEARCH_URL=http://localhost:7700
20
+ MEILISEARCH_API_KEY=your-master-key # optional for a local server
21
+ ```
22
+
23
+ These are environment variables rather than dashboard settings because the index is infrastructure — one server serves every store in the installation, and the reindex task has to reach it outside a request.
24
+
25
+ 2. Add the gem: `bundle add spree_meilisearch`
26
+
27
+ 3. Select the provider in `config/initializers/spree.rb`:
28
+
29
+ ```ruby
30
+ Spree.search_provider = 'SpreeMeilisearch::SearchProvider'
31
+ ```
32
+
33
+ 4. Build the index: `bin/rails spree:search:reindex`
34
+
35
+ From then on, product changes are indexed in the background as they happen. Re-run the reindex task after adding a custom field that should be searchable, sortable or filterable.
36
+
37
+ ## Upgrading from Spree 5.x
38
+
39
+ The provider used to live in `spree_core` as `Spree::SearchProvider::Meilisearch`. Add this gem and update the class name in your initializer:
40
+
41
+ ```ruby
42
+ # before
43
+ Spree.search_provider = 'Spree::SearchProvider::Meilisearch'
44
+ # after
45
+ Spree.search_provider = 'SpreeMeilisearch::SearchProvider'
46
+ ```
47
+
48
+ The old names still resolve with a deprecation warning for one release. Applications that subclassed `Spree::SearchProvider::ProductPresenter` should inherit from `SpreeMeilisearch::ProductPresenter` instead. No reindex is needed — the documents are unchanged.
49
+
50
+ ## Testing
51
+
52
+ ```bash
53
+ cd spree/providers/meilisearch
54
+ bundle install
55
+ bundle exec rake test_app
56
+ bundle exec rspec
57
+ ```
58
+
59
+ The unit specs stub the Meilisearch client and run offline. The integration spec in `spec/requests/` exercises a real server and is skipped unless `MEILISEARCH_URL` is set — locally, `brew install meilisearch && meilisearch`; in CI it runs as a service container.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/common_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task default: :spec
10
+
11
+ desc "Generates a dummy app for testing"
12
+ task :test_app do
13
+ ENV['LIB_NAME'] = 'spree_meilisearch'
14
+ Rake::Task['common:test_app'].invoke
15
+ end
@@ -0,0 +1,677 @@
1
+ module SpreeMeilisearch
2
+ # Product search, filtering and faceted navigation backed by a Meilisearch
3
+ # index — one index per store, holding the documents
4
+ # SpreeMeilisearch::ProductPresenter builds. Results are always intersected
5
+ # with the caller's ActiveRecord scope, so a stale index can never widen
6
+ # what a customer is allowed to see.
7
+ class SearchProvider < Spree::SearchProvider::Base
8
+ PREFIXED_ID_PATTERN = /\A[a-z]+_[A-Za-z0-9]+\z/
9
+ ALLOWED_STATUSES = %w[active draft archived paused].freeze
10
+ BUILT_IN_FILTERABLE_ATTRIBUTES = %w[product_id status in_stock preorder store_ids channel_ids locale currency available_on discontinue_on price category_ids collection_ids grouping_id tags option_value_ids option_value_combination_ids].freeze
11
+ CUSTOM_FIELD_RANGE_OPERATORS = { 'gt' => '>', 'gteq' => '>=', 'lt' => '<', 'lteq' => '<=' }.freeze
12
+
13
+ def self.indexing_required?
14
+ true
15
+ end
16
+
17
+ def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
18
+ page = [page.to_i, 1].max
19
+ limit = limit.to_i.clamp(1, 100)
20
+
21
+ filters = normalize_filters(filters)
22
+ sort, filters = resolve_manual_sort(sort, filters)
23
+
24
+ ms_result, _ = execute_search(query: query, filters: filters, sort: sort, page: page, limit: limit)
25
+ return empty_result(scope, page, limit) unless ms_result
26
+
27
+ # Hits have composite prefixed_id (prod_abc_en_USD), extract product_id (prod_abc)
28
+ product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq
29
+ raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
30
+
31
+ # Intersect with AR scope for security/visibility, preserving Meilisearch sort order.
32
+ # When the index could only pre-filter the option values, the database
33
+ # answers the same-variant question exactly here.
34
+ scope = scope.with_option_value_ids(@inexact_option_value_ids) if @inexact_option_value_ids.present?
35
+ products = if raw_ids.any?
36
+ records = scope.where(id: raw_ids).reorder(nil).index_by(&:id)
37
+ raw_ids.filter_map { |id| records[id] }
38
+ else
39
+ scope.none
40
+ end
41
+
42
+ pagy = build_pagy(ms_result, page, limit)
43
+
44
+ Spree::SearchProvider::SearchResult.new(
45
+ products: products,
46
+ total_count: ms_result['totalHits'] || 0,
47
+ pagy: pagy
48
+ )
49
+ end
50
+
51
+ def filters(scope:, query: nil, filters: {})
52
+ collection = filters.is_a?(Hash) ? (filters['_collection'] || filters[:_collection]) : nil
53
+ default_sort = collection ? to_api_sort(collection.sort_order) : 'manual'
54
+
55
+ ms_result, facet_distribution = execute_search(query: query, filters: filters, sort: nil, page: 1, limit: 0, return_facets: true)
56
+
57
+ unless ms_result
58
+ return Spree::SearchProvider::FiltersResult.new(
59
+ filters: [],
60
+ sort_options: built_in_and_custom_field_sort_options,
61
+ default_sort: default_sort,
62
+ total_count: 0
63
+ )
64
+ end
65
+
66
+ Spree::SearchProvider::FiltersResult.new(
67
+ filters: build_facet_response(facet_distribution, ms_result['facetStats'] || {}),
68
+ sort_options: built_in_and_custom_field_sort_options,
69
+ default_sort: default_sort,
70
+ total_count: ms_result['totalHits'] || 0
71
+ )
72
+ end
73
+
74
+ def index(product)
75
+ ensure_index_settings_once!
76
+ documents = presenter_class.new(product, store).call
77
+ # Delete first: membership docs use grouping-specific ids, so a product
78
+ # that left a grouping would otherwise leave an orphaned stale doc.
79
+ remove_by_id(product.prefixed_id)
80
+ client.index(index_name).add_documents(documents, 'id')
81
+ end
82
+
83
+ def remove(product)
84
+ remove_by_id(product.prefixed_id)
85
+ end
86
+
87
+ def index_batch(documents)
88
+ return if documents.empty?
89
+
90
+ client.index(index_name).add_documents(documents, 'id')
91
+ end
92
+
93
+ # Remove all documents for a product by its prefixed_id (e.g. 'prod_abc')
94
+ def remove_by_id(prefixed_id)
95
+ filter = "product_id = '#{sanitize_prefixed_id(prefixed_id)}'"
96
+ client.index(index_name).delete_documents(filter: filter)
97
+ rescue ::Meilisearch::ApiError => e
98
+ raise unless e.http_code == 404
99
+ end
100
+
101
+ def reindex(scope = nil)
102
+ full_reindex = scope.nil?
103
+ scope ||= store.products
104
+ @custom_field_schema = Spree::SearchProvider::CustomFieldSchema.new(store)
105
+ ensure_index_settings!
106
+
107
+ # On a full reindex, clear first so stale docs (e.g. membership docs for
108
+ # removed groupings) don't linger; a scoped reindex keeps upsert semantics.
109
+ client.index(index_name).delete_all_documents if full_reindex
110
+
111
+ indexed = 0
112
+ scope.reorder(id: :asc)
113
+ .preload_associations_lazily
114
+ .find_in_batches(batch_size: 500) do |batch|
115
+ documents = batch.flat_map { |product| presenter_class.new(product, store).call }
116
+ next if documents.empty?
117
+
118
+ index_batch(documents)
119
+ indexed += documents.size
120
+
121
+ Rails.logger.info { "[Meilisearch] Enqueued #{documents.size} documents (#{indexed} total) for #{index_name}" }
122
+ end
123
+
124
+ Rails.logger.info { "[Meilisearch] Reindex complete: #{indexed} documents enqueued for #{index_name}" }
125
+ indexed
126
+ end
127
+
128
+ # Configure index settings for filtering, sorting, and faceting.
129
+ # Called automatically by reindex, but can be called separately.
130
+ # Waits for all settings tasks to complete before returning so that
131
+ # subsequent add_documents calls use the correct filterable/sortable attributes.
132
+ def ensure_index_settings!
133
+ index = client.index(index_name)
134
+ tasks = []
135
+ tasks << index.update_filterable_attributes(filterable_attributes)
136
+ tasks << index.update_sortable_attributes(sortable_attributes)
137
+ tasks << index.update_searchable_attributes(searchable_attributes)
138
+ tasks << index.update_distinct_attribute(distinct_attribute)
139
+ tasks.each { |task| task&.await }
140
+ @index_settings_configured = true
141
+ end
142
+
143
+ # Lightweight guard — configures index settings once per provider instance.
144
+ # Meilisearch settings updates are idempotent, so repeated calls are safe
145
+ # but we avoid the overhead by memoizing.
146
+ def ensure_index_settings_once!
147
+ return if @index_settings_configured
148
+
149
+ ensure_index_settings!
150
+ end
151
+
152
+ private
153
+
154
+ # Normalize incoming filters to a plain, string-keyed Hash.
155
+ def normalize_filters(filters)
156
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
157
+ (filters || {}).stringify_keys
158
+ end
159
+
160
+ # Manual/position sort. When a single grouping is being viewed (in_collection
161
+ # or in_category) and the effective sort is 'manual' — either requested, or
162
+ # the grouping-page default (blank ⇒ manual) — query the per-grouping
163
+ # membership docs: swap the membership filter for `grouping_id` and sort by
164
+ # the scalar `position`. Without a grouping, 'manual' has nothing to order by,
165
+ # so it falls back to Meilisearch default ranking.
166
+ def resolve_manual_sort(sort, filters)
167
+ grouping_id = filters['in_collection'] || filters['in_category']
168
+ grouping_id = nil unless valid_prefixed_id?(grouping_id)
169
+ sort = 'manual' if sort.blank? && grouping_id
170
+
171
+ if sort == 'manual' && grouping_id
172
+ filters = filters.except('in_collection', 'in_category').merge('grouping_id' => grouping_id)
173
+ elsif sort == 'manual'
174
+ sort = nil
175
+ end
176
+
177
+ [sort, filters]
178
+ end
179
+
180
+ # Execute a Meilisearch query. Returns [ms_result, facet_distribution].
181
+ # facet_distribution is empty when return_facets is false. Returns nil on API error.
182
+ def execute_search(query:, filters:, sort:, page:, limit:, return_facets: false)
183
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
184
+ filters = (filters || {}).stringify_keys
185
+
186
+ option_value_ids = extract_and_delete(filters, 'with_option_value_ids')
187
+ grouped_options = group_option_values_by_type(Array(option_value_ids))
188
+ # Handed back to the caller so it can narrow the record scope exactly
189
+ # when the index cannot — see build_grouped_option_conditions.
190
+ @inexact_option_value_ids = option_exact?(grouped_options) ? nil : Array(option_value_ids)
191
+
192
+ base_conditions = build_filters(filters)
193
+ option_conditions = build_grouped_option_conditions(grouped_options)
194
+ all_conditions = base_conditions + option_conditions
195
+
196
+ # Page-based (exhaustive) pagination — REQUIRED so `distinct` collapses
197
+ # totalHits and facetDistribution to per-product counts. The offset/limit
198
+ # ("estimatedTotalHits") mode does NOT apply distinct to the count or
199
+ # facets, which over-counts the duplicated per-grouping membership docs.
200
+ search_params = {
201
+ filter: all_conditions,
202
+ facets: return_facets ? facet_attributes : nil,
203
+ sort: build_sort(sort),
204
+ page: page,
205
+ hitsPerPage: limit
206
+ }.compact
207
+
208
+ Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
209
+
210
+ settings_refreshed = false
211
+ begin
212
+ if return_facets && grouped_options.any?
213
+ queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
214
+ option_type_ids_ordered = grouped_options.keys
215
+ option_type_ids_ordered.each do |option_type_id|
216
+ without_this = build_grouped_option_conditions(grouped_options.except(option_type_id))
217
+ queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], page: 1, hitsPerPage: 0 }
218
+ end
219
+
220
+ results = client.multi_search(queries)
221
+ ms_result = results['results'][0]
222
+ facet_distribution = merge_disjunctive_facets(ms_result, results['results'][1..], option_type_ids_ordered)
223
+ else
224
+ ms_result = client.index(index_name).search(query.to_s, search_params)
225
+ facet_distribution = ms_result['facetDistribution'] || {}
226
+ end
227
+ rescue ::Meilisearch::ApiError => e
228
+ # Self-heal when a newly added filterable attribute (e.g. `preorder`)
229
+ # is referenced before the index settings were refreshed — otherwise
230
+ # every search on an upgraded store returns empty until a reindex or
231
+ # the next product write.
232
+ if %w[invalid_search_filter invalid_search_sort].include?(e.code) && !settings_refreshed
233
+ settings_refreshed = true
234
+ ensure_index_settings!
235
+ retry
236
+ end
237
+
238
+ Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
239
+ Rails.error.report(e, handled: true, context: { index: index_name, query: query })
240
+ return nil
241
+ end
242
+
243
+ Rails.logger.debug { "[Meilisearch] #{ms_result['totalHits']} hits in #{ms_result['processingTimeMs']}ms" }
244
+
245
+ [ms_result, facet_distribution]
246
+ end
247
+
248
+ def merge_disjunctive_facets(ms_result, disjunctive_results, option_type_ids_ordered)
249
+ main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {}
250
+
251
+ all_ov_prefixed_ids = Set.new
252
+ disjunctive_dists = {}
253
+ disjunctive_results.each_with_index do |r, idx|
254
+ dist = r.dig('facetDistribution', 'option_value_ids') || {}
255
+ disjunctive_dists[option_type_ids_ordered[idx]] = dist
256
+ all_ov_prefixed_ids.merge(dist.keys)
257
+ end
258
+
259
+ all_raw_ids = all_ov_prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
260
+ ov_to_type = Spree::OptionValue.where(id: all_raw_ids).pluck(:id, :option_type_id).to_h
261
+ prefixed_to_type = all_ov_prefixed_ids.each_with_object({}) do |pid, h|
262
+ raw = Spree::OptionValue.decode_prefixed_id(pid)
263
+ h[pid] = ov_to_type[raw] if raw
264
+ end
265
+
266
+ merged_ov_dist = main_ov_dist.dup
267
+ disjunctive_dists.each do |option_type_id, dist|
268
+ dist.each do |pid, count|
269
+ merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id
270
+ end
271
+ end
272
+
273
+ (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist)
274
+ end
275
+
276
+ def presenter_class
277
+ Spree::Dependencies.search_product_presenter_class
278
+ end
279
+
280
+ def client
281
+ @client ||= SpreeMeilisearch.client
282
+ end
283
+
284
+ def index_name
285
+ "#{store.code}_products"
286
+ end
287
+
288
+ def searchable_attributes
289
+ %w[name description sku option_values category_names tags] + custom_field_schema.searchable_attribute_keys
290
+ end
291
+
292
+ def filterable_attributes
293
+ BUILT_IN_FILTERABLE_ATTRIBUTES + custom_field_schema.filterable_attribute_keys
294
+ end
295
+
296
+ def sortable_attributes
297
+ %w[name price created_at available_on units_sold_count position] + custom_field_schema.sortable_attribute_keys
298
+ end
299
+
300
+ # Facets stay built-in only — cf_* distributions aren't consumed by
301
+ # +build_facet_response+, so requesting them would be wasted work.
302
+ def facet_attributes
303
+ BUILT_IN_FILTERABLE_ATTRIBUTES
304
+ end
305
+
306
+ # Collapses the per-grouping membership docs to one row per product on
307
+ # non-grouping queries (shop-all, search, non-manual sorts). Facet counts
308
+ # and totals honor distinct on Meilisearch >= 1.40.
309
+ def distinct_attribute
310
+ 'product_id'
311
+ end
312
+
313
+ # Sourced from Spree::Collection::SORT_ORDERS (matching FiltersAggregator) so
314
+ # both providers advertise identical options — including 'manual'.
315
+ def available_sort_options
316
+ Spree::Collection::SORT_ORDERS.map { |sort_order| to_api_sort(sort_order) } + custom_field_schema.sort_ids
317
+ end
318
+
319
+ # Converts internal sort format ('price asc') to API format ('price', '-price').
320
+ def to_api_sort(sort_value)
321
+ return sort_value unless sort_value.to_s.include?(' ')
322
+
323
+ field, direction = sort_value.split(' ', 2)
324
+ direction == 'desc' ? "-#{field}" : field
325
+ end
326
+
327
+ def built_in_and_custom_field_sort_options
328
+ Spree::Collection::SORT_ORDERS.map { |sort_order| { id: to_api_sort(sort_order), label: nil } } +
329
+ custom_field_schema.sort_options
330
+ end
331
+
332
+ # Build Meilisearch filter conditions from API params.
333
+ # Combines system scoping (always applied) with user-facing filters.
334
+ def build_filters(filters)
335
+ conditions = system_filter_conditions
336
+ conditions.concat(user_filter_conditions(filters))
337
+ conditions
338
+ end
339
+
340
+ # System scoping — always applied. Rarely overridden.
341
+ # Mirrors the AR scope: store.products.active(currency) with locale.
342
+ def system_filter_conditions
343
+ now = Time.current
344
+ conditions = []
345
+ conditions << "store_ids = '#{store.id}'"
346
+ conditions << "channel_ids = '#{Spree::Current.channel.id}'" if Spree::Current.channel
347
+ conditions << "status = 'active'"
348
+ conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
349
+ conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
350
+ # Exclude future-dated products — mirrors
351
+ # +Product.available(Time.current, include_preorderable: true)+. ISO 8601
352
+ # strings sort lexicographically in chronological order, so the string
353
+ # compare is sound; +NOT EXISTS+/+IS NULL+ keep the available_on clause
354
+ # backward-compatible with legacy docs. The trailing +preorder = true+
355
+ # keeps scheduled "coming soon" pre-orders searchable before their
356
+ # publish date. It filters on the new +preorder+ attribute, so a
357
+ # +rake spree:search:reindex+ (or the next product write, which refreshes
358
+ # the index settings) is required after deploy before it takes effect.
359
+ conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}' OR preorder = true)"
360
+ conditions << "(discontinue_on = 0 OR discontinue_on > #{now.to_i})"
361
+ conditions
362
+ end
363
+
364
+ # User-facing filters — override to add custom filter pre/post processing.
365
+ def user_filter_conditions(filters)
366
+ conditions = []
367
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
368
+ return conditions if filters.blank?
369
+
370
+ filters.each do |key, value|
371
+ next if value.blank?
372
+
373
+ condition = build_filter_condition(key.to_s, value)
374
+ if condition.is_a?(Array)
375
+ conditions.concat(condition)
376
+ elsif condition
377
+ conditions << condition
378
+ end
379
+ end
380
+
381
+ conditions
382
+ end
383
+
384
+ # Translate a single Ransack-style filter param into Meilisearch filter syntax.
385
+ # Override in subclasses to handle custom filter keys — call super for built-in filters.
386
+ def build_filter_condition(key, value)
387
+ case key
388
+ when 'price_gte'
389
+ "price >= #{value.to_f}"
390
+ when 'price_lte'
391
+ "price <= #{value.to_f}"
392
+ when 'in_stock'
393
+ 'in_stock = true' if value.to_s != '0'
394
+ when 'out_of_stock'
395
+ 'in_stock = false' if value.to_s != '0'
396
+ when 'in_category'
397
+ "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
398
+ when 'in_categories'
399
+ parts = Array(value).filter_map { |id| "category_ids = '#{sanitize_prefixed_id(id)}'" if valid_prefixed_id?(id) }
400
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
401
+ when 'in_collection'
402
+ "collection_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
403
+ when 'grouping_id'
404
+ "grouping_id = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
405
+ when 'with_option_value_ids'
406
+ # Handled by grouped option conditions in search_and_filter — skip here
407
+ nil
408
+ else
409
+ custom_field_filter_condition(key, value)
410
+ end
411
+ end
412
+
413
+ # Meilisearch has stable filter operators for equality, ranges, and
414
+ # EXISTS — but no string contains/starts/ends (CONTAINS is still
415
+ # experimental), so those predicates are ignored here. The Database
416
+ # provider supports the full predicate set.
417
+ def custom_field_filter_condition(key, value)
418
+ parsed = custom_field_schema.parse_filter(key)
419
+ return nil unless parsed
420
+
421
+ attribute = parsed[:definition].filter_key
422
+ numeric = parsed[:definition].field_type == 'number'
423
+
424
+ case parsed[:predicate]
425
+ when 'present'
426
+ "#{attribute} EXISTS"
427
+ when 'blank'
428
+ "#{attribute} NOT EXISTS"
429
+ when 'eq', 'not_eq'
430
+ operator = parsed[:predicate] == 'eq' ? '=' : '!='
431
+ formatted = format_custom_field_filter_value(value, numeric)
432
+ formatted && "#{attribute} #{operator} #{formatted}"
433
+ when *CUSTOM_FIELD_RANGE_OPERATORS.keys
434
+ return nil unless numeric
435
+
436
+ formatted = format_custom_field_filter_value(value, numeric)
437
+ formatted && "#{attribute} #{CUSTOM_FIELD_RANGE_OPERATORS[parsed[:predicate]]} #{formatted}"
438
+ end
439
+ end
440
+
441
+ def format_custom_field_filter_value(value, numeric)
442
+ if numeric
443
+ Float(value.to_s, exception: false)&.to_s
444
+ else
445
+ "'#{escape_filter_string(value)}'"
446
+ end
447
+ end
448
+
449
+ def escape_filter_string(value)
450
+ value.to_s.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }
451
+ end
452
+
453
+ # Group prefixed option value IDs by option type (single DB query).
454
+ # Returns { option_type_id => ['optval_abc', 'optval_def'], ... }
455
+ def group_option_values_by_type(prefixed_ids)
456
+ prefixed_ids = prefixed_ids.flatten.compact.select { |id| valid_prefixed_id?(id) }
457
+ return {} if prefixed_ids.empty?
458
+
459
+ raw_ids = prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) }
460
+ Spree::OptionValue.where(id: raw_ids).group_by(&:option_type_id).transform_values { |ovs| ovs.map(&:prefixed_id) }
461
+ end
462
+
463
+ # Build Meilisearch filter conditions from grouped option values.
464
+ # OR within each option type, AND across option types — and the AND has to
465
+ # hold within ONE variant, matching Spree::Product.with_option_value_ids.
466
+ #
467
+ # A single axis reads straight off the value union. Two or more axes ask
468
+ # instead for a combination token the presenter wrote per variant, so "blue
469
+ # AND XL" cannot be satisfied by a blue small sitting beside a red XL. One
470
+ # token per whole combination rather than per pair: pairs can each hold on
471
+ # a different variant while no variant carries them all.
472
+ #
473
+ # When the tokens cannot answer exactly — a filter naming more axes than
474
+ # the presenter indexed, or a swapped-in presenter that writes no tokens at
475
+ # all — this falls back to the per-axis union, a superset. On its own that
476
+ # superset would re-open the cross-variant bug, so `search_and_filter`
477
+ # detects the fallback and narrows the ActiveRecord scope through
478
+ # `Spree::Product.with_option_value_ids` before intersecting: the database
479
+ # gives the exact answer, Meilisearch only pre-filters. Rare by
480
+ # construction — MAX_COMBINATION_AXES covers any product a catalog
481
+ # realistically facets by — so its one cost, a page that can come back
482
+ # short, is accepted rather than designed around.
483
+ def build_grouped_option_conditions(grouped)
484
+ return single_axis_option_conditions(grouped) if grouped.size < 2
485
+ return single_axis_option_conditions(grouped) unless combination_tokens_cover?(grouped)
486
+
487
+ combinations = grouped.values[0].product(*grouped.values[1..])
488
+ tokens = combinations.map { |combination| option_combination_condition(combination) }
489
+ [tokens.length > 1 ? "(#{tokens.join(' OR ')})" : tokens.first]
490
+ end
491
+
492
+ # Whether the index actually holds a token for a filter this wide.
493
+ def combination_tokens_cover?(grouped)
494
+ max = max_combination_axes
495
+ !max.nil? && grouped.size <= max
496
+ end
497
+
498
+ # Whether the option conditions sent to Meilisearch answer the filter
499
+ # exactly. A single axis always does (the union IS the answer there); more
500
+ # need the combination tokens to be present for that width.
501
+ def option_exact?(grouped)
502
+ grouped.size < 2 || combination_tokens_cover?(grouped)
503
+ end
504
+
505
+ # Each id is sanitized on its own — the separator is not part of an id and
506
+ # the sanitizer would strip it.
507
+ def option_combination_condition(prefixed_ids)
508
+ token = prefixed_ids.map { |id| sanitize_prefixed_id(id) }.sort.join('|')
509
+ "option_value_combination_ids = '#{token}'"
510
+ end
511
+
512
+ # Read off the presenter, since it is what decides how deep the tokens go.
513
+ # Nil when a swapped-in presenter declares nothing: it writes no tokens, so
514
+ # filtering on them would match nothing at all.
515
+ #
516
+ # @return [Integer, nil]
517
+ def max_combination_axes
518
+ return unless presenter_class.const_defined?(:MAX_COMBINATION_AXES)
519
+
520
+ presenter_class::MAX_COMBINATION_AXES
521
+ end
522
+
523
+ def single_axis_option_conditions(grouped)
524
+ grouped.map do |_, prefixed_ids|
525
+ parts = prefixed_ids.map { |id| "option_value_ids = '#{sanitize_prefixed_id(id)}'" }
526
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
527
+ end
528
+ end
529
+
530
+ def extract_and_delete(hash, *keys)
531
+ keys.each do |key|
532
+ value = hash.delete(key) || hash.delete(key.to_sym)
533
+ return value if value.present?
534
+ end
535
+ nil
536
+ end
537
+
538
+ # Sort param to Meilisearch sort syntax.
539
+ # Override in subclasses to handle custom sort keys — call super for built-in sorts.
540
+ def build_sort(sort)
541
+ return nil if sort.blank?
542
+
543
+ sort_mapping(sort)
544
+ end
545
+
546
+ # Map a sort param to Meilisearch sort syntax.
547
+ # Override in subclasses to add custom sorts — call super for built-in sorts.
548
+ def sort_mapping(sort)
549
+ case sort
550
+ when 'manual' then ['position:asc']
551
+ when 'price' then ['price:asc']
552
+ when '-price' then ['price:desc']
553
+ when 'name' then ['name:asc']
554
+ when '-name' then ['name:desc']
555
+ when '-available_on' then ['available_on:desc']
556
+ when 'available_on' then ['available_on:asc']
557
+ when 'best_selling' then ['units_sold_count:desc']
558
+ else
559
+ parsed = custom_field_schema.parse_sort(sort)
560
+ parsed && ["#{parsed[:attribute]}:#{parsed[:direction]}"]
561
+ end
562
+ end
563
+
564
+ # Transform Meilisearch facetDistribution into standard filter response format.
565
+ # Override in subclasses to add custom facets — call super and append.
566
+ def build_facet_response(facet_distribution, facet_stats = {})
567
+ facets = []
568
+ facets << build_price_facet(facet_distribution['price'], facet_stats['price']) if facet_distribution['price'].present?
569
+ facets << build_availability_facet(facet_distribution['in_stock']) if facet_distribution['in_stock'].present?
570
+ facets.concat(build_option_facets(facet_distribution['option_value_ids'])) if facet_distribution['option_value_ids'].present?
571
+ facets << build_category_facet(facet_distribution['category_ids']) if facet_distribution['category_ids'].present?
572
+ facets.compact
573
+ end
574
+
575
+ # Bounds come from facetStats, which Meilisearch computes across every
576
+ # matching document. The distribution is capped at maxValuesPerFacet (100
577
+ # by default), so deriving min/max from its keys understates the range on
578
+ # any result set with more distinct prices than that.
579
+ def build_price_facet(distribution, stats = nil)
580
+ amounts = distribution.keys.map(&:to_f)
581
+ {
582
+ id: 'price',
583
+ type: 'price_range',
584
+ min: stats ? stats['min'].to_f : amounts.min,
585
+ max: stats ? stats['max'].to_f : amounts.max,
586
+ currency: currency
587
+ }
588
+ end
589
+
590
+ def build_availability_facet(distribution)
591
+ {
592
+ id: 'availability',
593
+ type: 'availability',
594
+ options: [
595
+ { id: 'in_stock', count: distribution['true'] || 0 },
596
+ { id: 'out_of_stock', count: distribution['false'] || 0 }
597
+ ]
598
+ }
599
+ end
600
+
601
+ def build_option_facets(distribution)
602
+ prefixed_ids = distribution.keys
603
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
604
+ option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
605
+
606
+ # Group by option type
607
+ by_option_type = {}
608
+ distribution.each do |ov_prefixed_id, count|
609
+ ov = option_values[ov_prefixed_id]
610
+ next unless ov
611
+
612
+ ot = ov.option_type
613
+ by_option_type[ot] ||= []
614
+ by_option_type[ot] << {
615
+ id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position,
616
+ color_code: ov.color_code,
617
+ image_url: ov.image.attached? ? Rails.application.routes.url_helpers.cdn_image_url(ov.image) : nil,
618
+ count: count
619
+ }
620
+ end
621
+
622
+ by_option_type.map do |option_type, values|
623
+ {
624
+ id: option_type.prefixed_id,
625
+ type: 'option',
626
+ name: option_type.name,
627
+ label: option_type.label,
628
+ kind: option_type.kind,
629
+ options: values.sort_by { |o| o[:position] }
630
+ }
631
+ end
632
+ end
633
+
634
+ def build_category_facet(distribution)
635
+ prefixed_ids = distribution.keys
636
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::Category.decode_prefixed_id(pid) }
637
+ categories = Spree::Category.where(id: raw_ids).index_by(&:prefixed_id)
638
+
639
+ {
640
+ id: 'categories',
641
+ type: 'category',
642
+ options: distribution.filter_map do |prefixed_id, count|
643
+ cat = categories[prefixed_id]
644
+ next unless cat
645
+
646
+ { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
647
+ end
648
+ }
649
+ end
650
+
651
+ def build_pagy(ms_result, page, limit)
652
+ fake_result = Struct.new(:raw_answer).new({
653
+ 'totalHits' => ms_result['totalHits'] || 0,
654
+ 'hitsPerPage' => limit,
655
+ 'page' => page
656
+ })
657
+
658
+ Pagy::MeilisearchPaginator.paginate(fake_result, {})
659
+ end
660
+
661
+ def empty_result(scope, page, limit)
662
+ Spree::SearchProvider::SearchResult.new(
663
+ products: scope.none,
664
+ total_count: 0,
665
+ pagy: Pagy::Offset.new(count: 0, page: page, limit: limit)
666
+ )
667
+ end
668
+
669
+ def valid_prefixed_id?(value)
670
+ value.to_s.match?(PREFIXED_ID_PATTERN)
671
+ end
672
+
673
+ def sanitize_prefixed_id(value)
674
+ value.to_s.gsub(/[^a-zA-Z0-9_]/, '')
675
+ end
676
+ end
677
+ end
@@ -0,0 +1,248 @@
1
+ module SpreeMeilisearch
2
+ # Builds the Meilisearch documents for a product. The shape is Meilisearch's
3
+ # own: flat, denormalized, one document per (locale × currency) the product is
4
+ # priced in, plus the membership documents a manual sort depends on.
5
+ class ProductPresenter
6
+ attr_reader :product, :store
7
+
8
+ def initialize(product, store)
9
+ @product = product
10
+ @store = store
11
+ end
12
+
13
+ # Returns an array of documents. For each (market × locale) the product is
14
+ # priced in, one BASE document plus one MEMBERSHIP document per grouping the
15
+ # product belongs to (each collection + each category ancestor-or-self),
16
+ # carrying a scalar grouping_id + position so Meilisearch can sort a grouping
17
+ # page by the merchant's hand-set position. Each document has flat name,
18
+ # description, price fields (no dynamic suffixes).
19
+ def call
20
+ documents = []
21
+
22
+ market_locale_pairs.each do |market, locale|
23
+ # Skip if product has no price in this currency
24
+ next unless lowest_price(market.currency)
25
+
26
+ Mobility.with_locale(locale) do
27
+ base = build_document(locale, market.currency, default_locale)
28
+ documents << base
29
+ documents.concat(membership_documents(base, locale, market.currency))
30
+ end
31
+ end
32
+
33
+ # Fallback for stores without markets (legacy/test)
34
+ if documents.empty?
35
+ fallback_currency = store.default_market&.currency || store.supported_currencies_list.first&.iso_code
36
+ if fallback_currency && lowest_price(fallback_currency)
37
+ base = build_document(default_locale, fallback_currency, default_locale)
38
+ documents << base
39
+ documents.concat(membership_documents(base, default_locale, fallback_currency))
40
+ end
41
+ end
42
+
43
+ documents
44
+ end
45
+
46
+ private
47
+
48
+ # Build a document for a given locale and currency
49
+ # @param locale [String] the locale to build the document for
50
+ # @param currency [String] the currency to build the document for
51
+ # @param fallback_locale [String] the fallback locale to use if the product has no translation for the given locale
52
+ # @return [Hash] the document
53
+ def build_document(locale, currency, fallback_locale)
54
+ {
55
+ # Composite ID: product + locale + currency
56
+ id: "#{product.prefixed_id}_#{locale}_#{currency}",
57
+ product_id: product.prefixed_id,
58
+ locale: locale.to_s,
59
+ currency: currency,
60
+ # Translated fields — with fallback to default locale
61
+ name: translated(product, :name, fallback_locale),
62
+ # Indexed as plain text — HTML tag names would otherwise become
63
+ # searchable tokens.
64
+ description: Spree::RichTextHelper.to_plain_text(translated(product, :description, fallback_locale)),
65
+ slug: translated(product, :slug, fallback_locale),
66
+ # Price in this currency
67
+ price: lowest_price(currency)&.to_f,
68
+ compare_at_price: compare_at_price(currency)&.to_f,
69
+ # Non-locale/currency fields
70
+ status: product.status,
71
+ sku: product.sku,
72
+ in_stock: product.in_stock?,
73
+ # True when the product has an active pre-order variant, so a
74
+ # scheduled (future-published) launch still surfaces in search.
75
+ preorder: product.preorder?,
76
+ store_ids: Array(product.store_id).map(&:to_s),
77
+ channel_ids: channel_ids_for_store,
78
+ discontinue_on: product.discontinue_on&.to_i || 0,
79
+ category_ids: category_ids_with_ancestors,
80
+ category_names: product.categories.map { |t| translated(t, :name, fallback_locale) },
81
+ collection_ids: product.collections.map(&:prefixed_id),
82
+ option_type_ids: product.option_types.map(&:prefixed_id),
83
+ option_type_names: product.option_types.map { |ot| translated(ot, :label, fallback_locale) },
84
+ option_value_ids: variant_option_value_ids,
85
+ option_value_combination_ids: variant_option_value_combination_ids,
86
+ option_values: variant_option_values_data.map { |ov| translated(ov, :label, fallback_locale) }.uniq,
87
+ tags: product.tag_list || [],
88
+ units_sold_count: product.units_sold_count || 0,
89
+ available_on: product.available_on&.iso8601,
90
+ created_at: product.created_at&.iso8601,
91
+ updated_at: product.updated_at&.iso8601
92
+ }.merge(custom_field_document_attributes)
93
+ end
94
+
95
+ # Flat cf_* hash for searchable ∪ sortable custom_fields (Meilisearch docs).
96
+ def custom_field_document_attributes
97
+ @custom_field_document_attributes ||= product.custom_fields.filter_map do |custom_field|
98
+ definition = custom_field.custom_field_definition
99
+ next unless definition&.searchable? || definition&.sortable?
100
+
101
+ [definition.filter_key, custom_field_index_value(custom_field, definition.field_type)]
102
+ end.to_h
103
+ end
104
+
105
+ def custom_field_index_value(custom_field, field_type)
106
+ serialized = custom_field.serialize_value
107
+ case field_type
108
+ when 'number'
109
+ serialized.to_f
110
+ else
111
+ serialized.to_s
112
+ end
113
+ end
114
+
115
+ # Returns all market × locale pairs for this store
116
+ def market_locale_pairs
117
+ @market_locale_pairs ||= store.markets.flat_map do |market|
118
+ market.supported_locales_list.map { |locale| [market, locale] }
119
+ end
120
+ end
121
+
122
+ def default_locale
123
+ @default_locale ||= store.default_market&.default_locale || I18n.default_locale.to_s
124
+ end
125
+
126
+ # Read a translated attribute with fallback to default locale.
127
+ def translated(record, attribute, fallback_locale)
128
+ value = record.send(attribute)
129
+ return value if value.present?
130
+
131
+ record.send(attribute, locale: fallback_locale.to_sym)
132
+ rescue ArgumentError
133
+ value
134
+ end
135
+
136
+ def lowest_price(currency)
137
+ @prices_cache ||= {}
138
+ @prices_cache[currency] = product.price_in(currency)&.amount unless @prices_cache.key?(currency)
139
+ @prices_cache[currency]
140
+ end
141
+
142
+ def compare_at_price(currency)
143
+ @compare_at_cache ||= {}
144
+ @compare_at_cache[currency] = product.compare_at_amount_in(currency) unless @compare_at_cache.key?(currency)
145
+ @compare_at_cache[currency]
146
+ end
147
+
148
+ def channel_ids_for_store
149
+ @channel_ids_for_store ||= product.product_publications
150
+ .joins(:channel)
151
+ .where(spree_channels: { store_id: store.id })
152
+ .pluck(:channel_id)
153
+ .map(&:to_s)
154
+ end
155
+
156
+ def category_ids_with_ancestors
157
+ @category_ids_with_ancestors ||= product.categories.flat_map { |t|
158
+ t.self_and_ancestors.map(&:prefixed_id)
159
+ }.uniq
160
+ end
161
+
162
+ # One membership document per grouping (each collection + each category
163
+ # ancestor-or-self) the product belongs to: the full base payload plus a
164
+ # scalar grouping_id and position, with a distinct composite id. A manual-
165
+ # sorted grouping page filters grouping_id and sorts by position.
166
+ def membership_documents(base, locale, currency)
167
+ grouping_positions.map do |grouping_id, position|
168
+ base.merge(
169
+ id: "#{product.prefixed_id}__#{grouping_id}_#{locale}_#{currency}",
170
+ grouping_id: grouping_id,
171
+ position: position
172
+ )
173
+ end
174
+ end
175
+
176
+ # { grouping_prefixed_id => position }, merged across collections (flat) and
177
+ # categories (subtree-MIN). Prefixes (coll_/ctg_) keep the keys disjoint.
178
+ def grouping_positions
179
+ @grouping_positions ||= collection_positions.merge(category_positions)
180
+ end
181
+
182
+ # Flat: one entry per collection with the product's ProductCollection.position.
183
+ def collection_positions
184
+ @collection_positions ||= product.product_collections.pluck(:collection_id, :position).each_with_object({}) do |(cid, pos), acc|
185
+ acc[prefixed_id_for(Spree::Collection, cid)] = pos
186
+ end
187
+ end
188
+
189
+ # Subtree-MIN: a product under a category AND its descendants contributes its
190
+ # position to every ancestor-or-self, folded to the minimum — mirrors the DB
191
+ # provider's MIN(position)/GROUP BY and category_ids_with_ancestors.
192
+ def category_positions
193
+ @category_positions ||= begin
194
+ positions_by_category_id = product.product_categories.pluck(:category_id, :position).to_h
195
+ product.categories.each_with_object({}) do |category, acc|
196
+ position = positions_by_category_id[category.id]
197
+ category.self_and_ancestors.each do |ancestor|
198
+ key = ancestor.prefixed_id
199
+ acc[key] = [acc[key], position].compact.min
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ # Encode a raw primary key into a model's prefixed id without loading the
206
+ # record (mirrors Spree::PrefixedId#prefixed_id).
207
+ def prefixed_id_for(klass, raw_id)
208
+ "#{klass._prefix_id_prefix}_#{Spree::PrefixedId::SQIDS.encode([raw_id])}"
209
+ end
210
+
211
+ def variant_option_value_ids
212
+ variant_option_values_data.map(&:prefixed_id).uniq
213
+ end
214
+
215
+ # One token per combination of option values that appear on the SAME
216
+ # variant, for every combination size from two up.
217
+ #
218
+ # The document is per product, so `option_value_ids` is a union across
219
+ # every variant and cannot answer "blue AND XL" — it matches a product
220
+ # selling a blue small beside a red XL. A token per whole combination
221
+ # restores the question the database scope asks, because a filter naming
222
+ # one value per axis matches exactly one of these tokens.
223
+ #
224
+ # Pairs alone are not enough: three variants (blue, XL, used), (blue, S,
225
+ # new) and (red, XL, new) satisfy every *pair* drawn from "blue AND XL AND
226
+ # new" while no single variant carries all three.
227
+ #
228
+ # Bounded by MAX_COMBINATION_AXES because the token count is exponential in
229
+ # the number of axes. A filter naming more axes than this falls back to the
230
+ # per-axis union in the search provider, which the database scope then
231
+ # narrows exactly — never to an approximation that would disagree with it.
232
+ MAX_COMBINATION_AXES = 6
233
+
234
+ def variant_option_value_combination_ids
235
+ product.variants.flat_map do |variant|
236
+ ids = variant.option_values.map(&:prefixed_id).sort
237
+ (2..[ids.size, MAX_COMBINATION_AXES].min).flat_map do |size|
238
+ ids.combination(size).map { |combination| combination.join('|') }
239
+ end
240
+ end.uniq
241
+ end
242
+
243
+ # Use variants (matches reindex preload) instead of variants.includes
244
+ def variant_option_values_data
245
+ @variant_option_values_data ||= product.variants.flat_map(&:option_values).uniq
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,25 @@
1
+ require 'rails/engine'
2
+
3
+ module SpreeMeilisearch
4
+ class Engine < Rails::Engine
5
+ engine_name 'spree_meilisearch'
6
+
7
+ # Gem name and module disagree on word boundaries (spree_meilisearch →
8
+ # SpreeMeilisearch), so Zeitwerk needs telling once.
9
+ initializer 'spree_meilisearch.inflections', before: :set_autoload_paths do
10
+ Rails.autoloaders.each do |autoloader|
11
+ autoloader.inflector.inflect('spree_meilisearch' => 'SpreeMeilisearch')
12
+ end
13
+ end
14
+
15
+ config.after_initialize do
16
+ # The document shape is Meilisearch's own — one document per locale and
17
+ # currency, plus per-grouping membership documents carrying the position a
18
+ # manual sort orders by. Installing the gem therefore also supplies the
19
+ # presenter that builds it, unless the application named its own subclass.
20
+ unless Spree::Dependencies.overridden?(:search_product_presenter)
21
+ Spree::Dependencies.search_product_presenter = 'SpreeMeilisearch::ProductPresenter'
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ require 'meilisearch'
2
+ require 'pagy/toolbox/paginators/meilisearch'
3
+ require 'spree_core'
4
+ require 'spree_meilisearch/engine'
5
+
6
+ module SpreeMeilisearch
7
+ # Meilisearch server the store's index lives on. Credentials are read from the
8
+ # environment rather than a Spree::Integration record because the index is
9
+ # infrastructure — one server serves every store in the installation, and the
10
+ # rake reindex task has to reach it outside a request.
11
+ def self.client
12
+ ::Meilisearch::Client.new(
13
+ ENV.fetch('MEILISEARCH_URL', 'http://localhost:7700'),
14
+ ENV['MEILISEARCH_API_KEY']
15
+ )
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_meilisearch
3
+ version: !ruby/object:Gem::Version
4
+ version: 6.0.0.beta1
5
+ platform: ruby
6
+ authors:
7
+ - Vendo Connect Inc.
8
+ - Vendo Sp. z o.o.
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2026-09-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: meilisearch
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0.28'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0.28'
28
+ - !ruby/object:Gem::Dependency
29
+ name: spree_core
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 6.0.0.beta1
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 6.0.0.beta1
42
+ description: Instant product search, filtering and faceted navigation for Spree via
43
+ Meilisearch
44
+ email: hello@spreecommerce.org
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - app/models/spree_meilisearch/search_provider.rb
53
+ - app/presenters/spree_meilisearch/product_presenter.rb
54
+ - lib/spree_meilisearch.rb
55
+ - lib/spree_meilisearch/engine.rb
56
+ homepage: https://spreecommerce.org
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ bug_tracker_uri: https://github.com/spree/spree/issues
61
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.beta1
62
+ documentation_uri: https://docs.spreecommerce.org/
63
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.0.beta1
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.2'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.22
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Meilisearch product search for Spree eCommerce platform
83
+ test_files: []