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 +7 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/Rakefile +15 -0
- data/app/models/spree_meilisearch/search_provider.rb +677 -0
- data/app/presenters/spree_meilisearch/product_presenter.rb +248 -0
- data/lib/spree_meilisearch/engine.rb +25 -0
- data/lib/spree_meilisearch.rb +17 -0
- metadata +83 -0
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: []
|