spree_core 5.4.0.rc1 → 5.4.0.rc2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb701f06817ccd3c82b765594a0c66bbb23e6daec6d4c33600ecbfa34f0581f5
4
- data.tar.gz: be65bdb6937fefda9bd82cefe65f1115259ff268f7f8ff59dce5b483919f4192
3
+ metadata.gz: 5de0595dccb2046acb60805390c641e66c9e87c070c25df2587d5a8eaf9bc358
4
+ data.tar.gz: 6cd99e1b81481dd826565d1f579ea64965dfa04a808905f830505110f330473d
5
5
  SHA512:
6
- metadata.gz: dcc8cb038dd29a19596e68875c041f9d3a2247e6fdac6548647ef58b0d41446afbd6524ca4efb2e0e09acc5d15fa7043bec9f5d57cf25b1676c2c9209888ba52
7
- data.tar.gz: 2ebdb54fc12315d6102df129c38f663ec56c4bc267fdd7d2d2729cb4b39494b7b033cc14b949efc2f370c0a63ad6aafd17c1dc3ebffb8662ff94123a2cc78f8e
6
+ metadata.gz: 1d27ab08a797c2f5419f36bd3ad368f1cecaf6610d1ca9289e9b5dea81f62a4856b8b767b23129b1dd20e0e0fd0c02f619581e58b7b347642cc801a2808de453
7
+ data.tar.gz: d551a808c3d989cf88e02c18b00bd9582b7672459b59e9c0ea281b6797cf5b0b95b698e32f98adc7a180723ab98e84aae99bc163ac5ee7de7a3d6745abc97e80
@@ -1,3 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
- <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" />
3
- </svg>
1
+ <svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-banktransfer"><title id="pi-banktransfer">Bank Transfer</title><g clip-path="url(#pi-banktransfer-clip)"><path opacity=".07" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z" fill="#000"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M10 7l-4 2v1h8V9L10 7zM7 10.75v3.5h1v-3.5H7zm2 0v3.5h1v-3.5H9zm2 0v3.5h1.5v-3.5H11zM6 15v1h8v-1H6zM28 7l-4 2v1h8V9l-4-2zm-3 3.75v3.5h1v-3.5h-1zm2 0v3.5h1v-3.5h-1zm2 0v3.5h1.5v-3.5H29zM24 15v1h8v-1h-8z" fill="#4A4A4A"/><path d="M15.5 10h7M15.5 10l1.5-1.25M15.5 10l1.5 1.25" stroke="#4A4A4A" stroke-width=".9" stroke-linecap="round" stroke-linejoin="round"/><path d="M22.5 14h-7M22.5 14l-1.5-1.25M22.5 14l-1.5 1.25" stroke="#4A4A4A" stroke-width=".9" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="pi-banktransfer-clip"><path fill="#fff" d="M0 0h38v24H0z"/></clipPath></defs></svg>
@@ -29,7 +29,7 @@ module Spree
29
29
  # => { id: 1, name: "Shirt", price_USD: 19.99, ... }
30
30
  def search_presentation(store = nil)
31
31
  store ||= Spree::Current.store
32
- Spree::SearchProvider::ProductPresenter.new(self, store).call
32
+ Spree::Dependencies.search_product_presenter_class.new(self, store).call
33
33
  end
34
34
 
35
35
  # Remove this record from search index synchronously (inline, no job).
@@ -106,12 +106,26 @@ module Spree
106
106
  end
107
107
 
108
108
  def user_default_billing?
109
- user.present? && id == user.bill_address_id
109
+ Spree::Deprecation.warn('Spree::Address#user_default_billing? is deprecated and will be removed in Spree 6.0. Use #is_default_billing? instead.')
110
+ is_default_billing?
110
111
  end
111
112
 
112
113
  def user_default_shipping?
114
+ Spree::Deprecation.warn('Spree::Address#user_default_shipping? is deprecated and will be removed in Spree 6.0. Use #is_default_shipping? instead.')
115
+ is_default_shipping?
116
+ end
117
+
118
+ # In 6.0 these become real columns on Address, replacing User#bill_address_id / ship_address_id.
119
+ # For now they delegate to the User FK so the API shape is stable.
120
+ def is_default_billing?
121
+ user.present? && id == user.bill_address_id
122
+ end
123
+ alias_method :is_default_billing, :is_default_billing?
124
+
125
+ def is_default_shipping?
113
126
  user.present? && id == user.ship_address_id
114
127
  end
128
+ alias_method :is_default_shipping, :is_default_shipping?
115
129
 
116
130
  # first_name / last_name aliases are defined via alias_attribute above
117
131
 
@@ -1,3 +1,5 @@
1
+ require 'pagy/toolbox/paginators/meilisearch'
2
+
1
3
  module Spree
2
4
  module SearchProvider
3
5
  class Meilisearch < Base
@@ -66,7 +68,7 @@ module Spree
66
68
  end
67
69
 
68
70
  def index(product)
69
- documents = ProductPresenter.new(product, store).call
71
+ documents = presenter_class.new(product, store).call
70
72
  client.index(index_name).add_documents(documents, 'prefixed_id')
71
73
  end
72
74
 
@@ -91,9 +93,9 @@ module Spree
91
93
  ensure_index_settings!
92
94
 
93
95
  scope.reorder(id: :asc)
94
- .preload(*ProductPresenter::REQUIRED_PRELOADS)
96
+ .preload_associations_lazily
95
97
  .find_in_batches(batch_size: 500) do |batch|
96
- documents = batch.flat_map { |product| ProductPresenter.new(product, store).call }
98
+ documents = batch.flat_map { |product| presenter_class.new(product, store).call }
97
99
  index_batch(documents)
98
100
  end
99
101
  end
@@ -109,6 +111,10 @@ module Spree
109
111
 
110
112
  private
111
113
 
114
+ def presenter_class
115
+ Spree::Dependencies.search_product_presenter_class
116
+ end
117
+
112
118
  def client
113
119
  @client ||= ::Meilisearch::Client.new(
114
120
  ENV.fetch('MEILISEARCH_URL', 'http://localhost:7700'),
@@ -140,152 +146,165 @@ module Spree
140
146
  %w[price -price name -name -available_on available_on best_selling]
141
147
  end
142
148
 
143
- # Convert Ransack-style filters to Meilisearch filter syntax.
144
- # Always applies store scoping, active status, and currency availability
145
- # so that Meilisearch results match what the AR scope would return.
146
- # All values are sanitized to prevent filter injection.
149
+ # Build Meilisearch filter conditions from API params.
150
+ # Combines system scoping (always applied) with user-facing filters.
147
151
  def build_filters(filters)
148
- conditions = []
152
+ conditions = system_filter_conditions
153
+ conditions.concat(user_filter_conditions(filters))
154
+ conditions
155
+ end
149
156
 
150
- # Always scope to current store, locale, currency, active, not discontinued.
151
- # This mirrors the AR scope: store.products.active(currency) with locale
157
+ # System scoping always applied. Rarely overridden.
158
+ # Mirrors the AR scope: store.products.active(currency) with locale.
159
+ def system_filter_conditions
160
+ conditions = []
152
161
  conditions << "store_ids = '#{store.id}'"
153
162
  conditions << "status = 'active'"
154
163
  conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
155
164
  conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
156
165
  conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
166
+ conditions
167
+ end
157
168
 
169
+ # User-facing filters — override to add custom filter pre/post processing.
170
+ def user_filter_conditions(filters)
171
+ conditions = []
158
172
  filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
159
173
  return conditions if filters.blank?
160
174
 
161
175
  filters.each do |key, value|
162
176
  next if value.blank?
163
177
 
164
- key = key.to_s
165
- case key
166
- when 'price_gte'
167
- conditions << "price >= #{value.to_f}"
168
- when 'price_lte'
169
- conditions << "price <= #{value.to_f}"
170
- when 'in_stock'
171
- conditions << 'in_stock = true' if value.to_s != '0'
172
- when 'out_of_stock'
173
- conditions << 'in_stock = false' if value.to_s != '0'
174
- when 'categories_id_eq'
175
- conditions << "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
176
- when 'with_option_value_ids'
177
- Array(value).each do |ov|
178
- conditions << "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov)
179
- end
178
+ condition = build_filter_condition(key.to_s, value)
179
+ if condition.is_a?(Array)
180
+ conditions.concat(condition)
181
+ elsif condition
182
+ conditions << condition
180
183
  end
181
184
  end
182
185
 
183
186
  conditions
184
187
  end
185
188
 
189
+ # Translate a single Ransack-style filter param into Meilisearch filter syntax.
190
+ # Override in subclasses to handle custom filter keys — call super for built-in filters.
191
+ def build_filter_condition(key, value)
192
+ case key
193
+ when 'price_gte'
194
+ "price >= #{value.to_f}"
195
+ when 'price_lte'
196
+ "price <= #{value.to_f}"
197
+ when 'in_stock'
198
+ 'in_stock = true' if value.to_s != '0'
199
+ when 'out_of_stock'
200
+ 'in_stock = false' if value.to_s != '0'
201
+ when 'categories_id_eq'
202
+ "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
203
+ when 'with_option_value_ids'
204
+ Array(value).filter_map { |ov| "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov) }
205
+ end
206
+ end
207
+
208
+ # Sort param to Meilisearch sort syntax.
209
+ # Override in subclasses to handle custom sort keys — call super for built-in sorts.
186
210
  def build_sort(sort)
187
211
  return nil if sort.blank?
188
212
 
213
+ sort_mapping(sort)
214
+ end
215
+
216
+ # Map a sort param to Meilisearch sort syntax.
217
+ # Override in subclasses to add custom sorts — call super for built-in sorts.
218
+ def sort_mapping(sort)
189
219
  case sort
190
- when 'price'
191
- ['price:asc']
192
- when '-price'
193
- ['price:desc']
194
- when 'name'
195
- ['name:asc']
196
- when '-name'
197
- ['name:desc']
198
- when '-available_on'
199
- ['available_on:desc']
200
- when 'available_on'
201
- ['available_on:asc']
202
- when 'best_selling'
203
- ['units_sold_count:desc']
220
+ when 'price' then ['price:asc']
221
+ when '-price' then ['price:desc']
222
+ when 'name' then ['name:asc']
223
+ when '-name' then ['name:desc']
224
+ when '-available_on' then ['available_on:desc']
225
+ when 'available_on' then ['available_on:asc']
226
+ when 'best_selling' then ['units_sold_count:desc']
204
227
  end
205
228
  end
206
229
 
207
- # Transform Meilisearch facetDistribution into our standard filter response format
230
+ # Transform Meilisearch facetDistribution into standard filter response format.
231
+ # Override in subclasses to add custom facets — call super and append.
208
232
  def build_facet_response(facet_distribution)
209
- filters = []
210
-
211
- # Price range
212
- if facet_distribution['price'].present?
213
- amounts = facet_distribution['price'].keys.map(&:to_f)
214
- filters << {
215
- id: 'price',
216
- type: 'price_range',
217
- min: amounts.min,
218
- max: amounts.max,
219
- currency: currency
220
- }
221
- end
233
+ facets = []
234
+ facets << build_price_facet(facet_distribution['price']) if facet_distribution['price'].present?
235
+ facets << build_availability_facet(facet_distribution['in_stock']) if facet_distribution['in_stock'].present?
236
+ facets.concat(build_option_facets(facet_distribution['option_value_ids'])) if facet_distribution['option_value_ids'].present?
237
+ facets << build_category_facet(facet_distribution['category_ids']) if facet_distribution['category_ids'].present?
238
+ facets.compact
239
+ end
222
240
 
223
- # Availability
224
- if facet_distribution['in_stock'].present?
225
- in_stock = facet_distribution['in_stock']['true'] || 0
226
- out_of_stock = facet_distribution['in_stock']['false'] || 0
227
- filters << {
228
- id: 'availability',
229
- type: 'availability',
230
- options: [
231
- { id: 'in_stock', count: in_stock },
232
- { id: 'out_of_stock', count: out_of_stock }
233
- ]
234
- }
235
- end
241
+ def build_price_facet(distribution)
242
+ amounts = distribution.keys.map(&:to_f)
243
+ {
244
+ id: 'price',
245
+ type: 'price_range',
246
+ min: amounts.min,
247
+ max: amounts.max,
248
+ currency: currency
249
+ }
250
+ end
236
251
 
237
- # Option values — group by option type for faceted display
238
- if facet_distribution['option_value_ids'].present?
239
- prefixed_ids = facet_distribution['option_value_ids'].keys
240
- raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
241
- option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
242
-
243
- # Group by option type
244
- by_option_type = {}
245
- facet_distribution['option_value_ids'].each do |ov_prefixed_id, count|
246
- ov = option_values[ov_prefixed_id]
247
- next unless ov
248
-
249
- ot = ov.option_type
250
- by_option_type[ot] ||= []
251
- by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
252
- end
252
+ def build_availability_facet(distribution)
253
+ {
254
+ id: 'availability',
255
+ type: 'availability',
256
+ options: [
257
+ { id: 'in_stock', count: distribution['true'] || 0 },
258
+ { id: 'out_of_stock', count: distribution['false'] || 0 }
259
+ ]
260
+ }
261
+ end
253
262
 
254
- by_option_type.each do |option_type, values|
255
- filters << {
256
- id: option_type.prefixed_id,
257
- type: 'option',
258
- name: option_type.name,
259
- label: option_type.label,
260
- options: values.sort_by { |o| o[:position] }
261
- }
262
- end
263
+ def build_option_facets(distribution)
264
+ prefixed_ids = distribution.keys
265
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
266
+ option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
267
+
268
+ # Group by option type
269
+ by_option_type = {}
270
+ distribution.each do |ov_prefixed_id, count|
271
+ ov = option_values[ov_prefixed_id]
272
+ next unless ov
273
+
274
+ ot = ov.option_type
275
+ by_option_type[ot] ||= []
276
+ by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
263
277
  end
264
278
 
265
- # Categories
266
- if facet_distribution['category_ids'].present?
267
- prefixed_ids = facet_distribution['category_ids'].keys
268
- raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
269
- categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
270
-
271
- filters << {
272
- id: 'categories',
273
- type: 'category',
274
- options: facet_distribution['category_ids'].filter_map do |prefixed_id, count|
275
- cat = categories[prefixed_id]
276
- next unless cat
277
-
278
- { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
279
- end
279
+ by_option_type.map do |option_type, values|
280
+ {
281
+ id: option_type.prefixed_id,
282
+ type: 'option',
283
+ name: option_type.name,
284
+ label: option_type.label,
285
+ options: values.sort_by { |o| o[:position] }
280
286
  }
281
287
  end
288
+ end
289
+
290
+ def build_category_facet(distribution)
291
+ prefixed_ids = distribution.keys
292
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
293
+ categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
282
294
 
283
- filters
295
+ {
296
+ id: 'categories',
297
+ type: 'category',
298
+ options: distribution.filter_map do |prefixed_id, count|
299
+ cat = categories[prefixed_id]
300
+ next unless cat
301
+
302
+ { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
303
+ end
304
+ }
284
305
  end
285
306
 
286
307
  def build_pagy(ms_result, page, limit)
287
- require 'pagy/toolbox/paginators/meilisearch'
288
-
289
308
  fake_result = Struct.new(:raw_answer).new({
290
309
  'totalHits' => ms_result['estimatedTotalHits'] || 0,
291
310
  'hitsPerPage' => limit,
@@ -1,12 +1,6 @@
1
1
  module Spree
2
2
  module SearchProvider
3
3
  class ProductPresenter
4
- # Associations needed by this presenter — used by reindex and rake task for preloading
5
- REQUIRED_PRELOADS = [
6
- :taxons, :option_types, :primary_media, :store_products,
7
- { variants_including_master: [:prices, :option_values] }
8
- ].freeze
9
-
10
4
  attr_reader :product, :store
11
5
 
12
6
  def initialize(product, store)
@@ -8,8 +8,8 @@ module Spree
8
8
 
9
9
  def call(address_params: {}, user: nil, **opts)
10
10
  order = opts[:order]
11
- default_billing = opts.fetch(:default_billing, false)
12
- default_shipping = opts.fetch(:default_shipping, false)
11
+ default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
+ default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
13
 
14
14
  address_params = fill_country_and_state_ids(address_params)
15
15
 
@@ -8,8 +8,8 @@ module Spree
8
8
 
9
9
  def call(address:, address_params:, **opts)
10
10
  order = opts[:order]
11
- default_billing = opts.fetch(:default_billing, false)
12
- default_shipping = opts.fetch(:default_shipping, false)
11
+ default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
+ default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
13
  address_changes_except = opts.fetch(:address_changes_except, [])
14
14
  create_new_address_on_update = opts.fetch(:create_new_address_on_update, false)
15
15
  Spree::Deprecation.warn('Spree::Addresses::Update create_new_address_on_update parameter is deprecated and will be removed in Spree 5.5.') if create_new_address_on_update
@@ -103,7 +103,10 @@ module Spree
103
103
  products_finder: 'Spree::Products::Find',
104
104
  taxon_finder: 'Spree::Taxons::Find',
105
105
  line_item_by_variant_finder: 'Spree::LineItems::FindByVariant',
106
- variant_finder: 'Spree::Variants::Find'
106
+ variant_finder: 'Spree::Variants::Find',
107
+
108
+ # search
109
+ search_product_presenter: 'Spree::SearchProvider::ProductPresenter'
107
110
  }.freeze
108
111
 
109
112
  include Spree::DependenciesHelper
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.rc1'.freeze
2
+ VERSION = '5.4.0.rc2'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0.rc1
4
+ version: 5.4.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-03-20 00:00:00.000000000 Z
13
+ date: 2026-03-23 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1695,9 +1695,9 @@ licenses:
1695
1695
  - BSD-3-Clause
1696
1696
  metadata:
1697
1697
  bug_tracker_uri: https://github.com/spree/spree/issues
1698
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc1
1698
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc2
1699
1699
  documentation_uri: https://docs.spreecommerce.org/
1700
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc1
1700
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc2
1701
1701
  post_install_message:
1702
1702
  rdoc_options: []
1703
1703
  require_paths: