spree_core 5.4.0.beta8 → 5.4.0.beta10
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 +4 -4
- data/app/finders/spree/products/find.rb +1 -1
- data/app/jobs/spree/search_provider/index_job.rb +22 -0
- data/app/jobs/spree/search_provider/remove_job.rb +19 -0
- data/app/models/concerns/spree/product_scopes.rb +95 -135
- data/app/models/concerns/spree/search_indexable.rb +83 -0
- data/app/models/concerns/spree/{multi_searchable.rb → searchable.rb} +10 -3
- data/app/models/concerns/spree/user_methods.rb +11 -8
- data/app/models/spree/address.rb +3 -15
- data/app/models/spree/credit_card.rb +1 -0
- data/app/models/spree/inventory_unit.rb +1 -1
- data/app/models/spree/line_item.rb +4 -0
- data/app/models/spree/metafield_definition.rb +7 -4
- data/app/models/spree/option_type.rb +3 -0
- data/app/models/spree/option_value.rb +3 -0
- data/app/models/spree/order.rb +27 -9
- data/app/models/spree/order_promotion.rb +1 -1
- data/app/models/spree/product.rb +9 -31
- data/app/models/spree/refresh_token.rb +60 -0
- data/app/models/spree/search_provider/base.rb +81 -0
- data/app/models/spree/search_provider/database.rb +95 -0
- data/app/models/spree/search_provider/meilisearch.rb +302 -0
- data/app/models/spree/search_provider/search_result.rb +5 -0
- data/app/models/spree/shipment.rb +1 -1
- data/app/models/spree/shipping_method.rb +1 -1
- data/app/models/spree/shipping_rate.rb +1 -1
- data/app/models/spree/taxon.rb +2 -7
- data/app/models/spree/variant.rb +6 -25
- data/app/models/spree/wishlist.rb +1 -0
- data/app/presenters/spree/search_provider/product_presenter.rb +131 -0
- data/app/services/spree/carts/update.rb +5 -4
- data/db/migrate/20260317000000_create_spree_refresh_tokens.rb +16 -0
- data/db/sample_data/payment_methods.rb +2 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +16 -3
- data/lib/tasks/search.rake +15 -0
- metadata +30 -6
- data/app/models/spree/cart_promotion.rb +0 -7
data/app/models/spree/address.rb
CHANGED
|
@@ -83,6 +83,8 @@ module Spree
|
|
|
83
83
|
delegate :abbr, to: :state, prefix: true, allow_nil: true
|
|
84
84
|
|
|
85
85
|
alias_attribute :postal_code, :zipcode
|
|
86
|
+
alias_attribute :first_name, :firstname
|
|
87
|
+
alias_attribute :last_name, :lastname
|
|
86
88
|
|
|
87
89
|
# Writer methods for API convenience - these set country/state from ISO/abbr codes
|
|
88
90
|
# The reader methods (country_iso, state_abbr) are delegates to country.iso and state.abbr
|
|
@@ -111,21 +113,7 @@ module Spree
|
|
|
111
113
|
user.present? && id == user.ship_address_id
|
|
112
114
|
end
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
firstname
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def first_name=(value)
|
|
119
|
-
self.firstname = value
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def last_name
|
|
123
|
-
lastname
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def last_name=(value)
|
|
127
|
-
self.lastname = value
|
|
128
|
-
end
|
|
116
|
+
# first_name / last_name aliases are defined via alias_attribute above
|
|
129
117
|
|
|
130
118
|
def full_name
|
|
131
119
|
"#{firstname} #{lastname}".strip
|
|
@@ -102,6 +102,10 @@ module Spree
|
|
|
102
102
|
alias single_money display_price
|
|
103
103
|
alias single_display_amount display_price
|
|
104
104
|
|
|
105
|
+
# 5.5 API naming bridges (DB column rename in 6.0)
|
|
106
|
+
alias_attribute :discount_total, :promo_total
|
|
107
|
+
alias display_discount_total display_promo_total
|
|
108
|
+
|
|
105
109
|
def discounted_price
|
|
106
110
|
return price if quantity.zero?
|
|
107
111
|
|
|
@@ -23,10 +23,10 @@ module Spree
|
|
|
23
23
|
# Scopes
|
|
24
24
|
#
|
|
25
25
|
scope :for_resource_type, ->(resource_type) { where(resource_type: resource_type) }
|
|
26
|
-
scope :
|
|
27
|
-
return all if
|
|
26
|
+
scope :search, ->(query) do
|
|
27
|
+
return all if query.blank?
|
|
28
28
|
|
|
29
|
-
search_term = "%#{
|
|
29
|
+
search_term = "%#{query.downcase}%"
|
|
30
30
|
namespace_condition = arel_table[:namespace].lower.matches(search_term)
|
|
31
31
|
key_condition = arel_table[:key].lower.matches(search_term)
|
|
32
32
|
name_condition = arel_table[:name].lower.matches(search_term)
|
|
@@ -34,6 +34,9 @@ module Spree
|
|
|
34
34
|
where(namespace_condition.or(key_condition).or(name_condition))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Backward compatibility alias — remove in Spree 6.0
|
|
38
|
+
scope :multi_search, ->(*args) { search(*args) }
|
|
39
|
+
|
|
37
40
|
#
|
|
38
41
|
# Callbacks
|
|
39
42
|
#
|
|
@@ -46,7 +49,7 @@ module Spree
|
|
|
46
49
|
# Ransack
|
|
47
50
|
#
|
|
48
51
|
self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
|
|
49
|
-
self.whitelisted_ransackable_scopes = %w[multi_search]
|
|
52
|
+
self.whitelisted_ransackable_scopes = %w[search multi_search]
|
|
50
53
|
|
|
51
54
|
# Returns the full key with namespace
|
|
52
55
|
# @return [String] eg. custom.id
|
|
@@ -33,6 +33,9 @@ module Spree
|
|
|
33
33
|
has_many :option_type_prototypes, class_name: 'Spree::OptionTypePrototype'
|
|
34
34
|
has_many :prototypes, through: :option_type_prototypes, class_name: 'Spree::Prototype'
|
|
35
35
|
|
|
36
|
+
# 5.5 API naming bridge (DB column rename in 6.0)
|
|
37
|
+
alias_attribute :label, :presentation
|
|
38
|
+
|
|
36
39
|
#
|
|
37
40
|
# Validations
|
|
38
41
|
#
|
|
@@ -28,6 +28,9 @@ module Spree
|
|
|
28
28
|
has_many :variants, through: :option_value_variants, class_name: 'Spree::Variant'
|
|
29
29
|
has_many :products, through: :variants, class_name: 'Spree::Product'
|
|
30
30
|
|
|
31
|
+
# 5.5 API naming bridge (DB column rename in 6.0)
|
|
32
|
+
alias_attribute :label, :presentation
|
|
33
|
+
|
|
31
34
|
#
|
|
32
35
|
# Validations
|
|
33
36
|
#
|
data/app/models/spree/order.rb
CHANGED
|
@@ -32,7 +32,7 @@ module Spree
|
|
|
32
32
|
include Spree::MemoizedData
|
|
33
33
|
include Spree::Metafields
|
|
34
34
|
include Spree::Metadata
|
|
35
|
-
include Spree::
|
|
35
|
+
include Spree::Searchable
|
|
36
36
|
if defined?(Spree::Security::Orders)
|
|
37
37
|
include Spree::Security::Orders
|
|
38
38
|
end
|
|
@@ -54,6 +54,12 @@ module Spree
|
|
|
54
54
|
alias display_ship_total display_shipment_total
|
|
55
55
|
alias_attribute :ship_total, :shipment_total
|
|
56
56
|
|
|
57
|
+
# 5.5 API naming bridges (DB columns rename in 6.0)
|
|
58
|
+
alias_attribute :discount_total, :promo_total
|
|
59
|
+
alias display_discount_total display_promo_total
|
|
60
|
+
alias_attribute :customer_note, :special_instructions
|
|
61
|
+
alias_attribute :total_quantity, :item_count
|
|
62
|
+
|
|
57
63
|
MONEY_THRESHOLD = 100_000_000
|
|
58
64
|
MONEY_VALIDATION = {
|
|
59
65
|
presence: true,
|
|
@@ -87,7 +93,7 @@ module Spree
|
|
|
87
93
|
completed_at email number state payment_state shipment_state
|
|
88
94
|
total item_total item_count considered_risky channel
|
|
89
95
|
]
|
|
90
|
-
self.whitelisted_ransackable_scopes = %w[refunded partially_refunded multi_search]
|
|
96
|
+
self.whitelisted_ransackable_scopes = %w[refunded partially_refunded search multi_search]
|
|
91
97
|
|
|
92
98
|
attr_reader :coupon_code
|
|
93
99
|
attr_accessor :temporary_address
|
|
@@ -108,11 +114,13 @@ module Spree
|
|
|
108
114
|
optional: true, dependent: :destroy
|
|
109
115
|
alias_method :billing_address, :bill_address
|
|
110
116
|
alias_method :billing_address=, :bill_address=
|
|
117
|
+
alias_attribute :billing_address_id, :bill_address_id
|
|
111
118
|
|
|
112
119
|
belongs_to :ship_address, foreign_key: :ship_address_id, class_name: 'Spree::Address',
|
|
113
120
|
optional: true, dependent: :destroy
|
|
114
121
|
alias_method :shipping_address, :ship_address
|
|
115
122
|
alias_method :shipping_address=, :ship_address=
|
|
123
|
+
alias_attribute :shipping_address_id, :ship_address_id
|
|
116
124
|
|
|
117
125
|
belongs_to :store, class_name: 'Spree::Store'
|
|
118
126
|
|
|
@@ -139,7 +147,6 @@ module Spree
|
|
|
139
147
|
inverse_of: :order
|
|
140
148
|
|
|
141
149
|
has_many :order_promotions, class_name: 'Spree::OrderPromotion'
|
|
142
|
-
has_many :cart_promotions, class_name: 'Spree::CartPromotion', foreign_key: :order_id
|
|
143
150
|
has_many :promotions, through: :order_promotions, class_name: 'Spree::Promotion'
|
|
144
151
|
|
|
145
152
|
has_many :shipments, class_name: 'Spree::Shipment', dependent: :destroy, inverse_of: :order do
|
|
@@ -150,10 +157,18 @@ module Spree
|
|
|
150
157
|
has_many :shipment_adjustments, through: :shipments, source: :adjustments
|
|
151
158
|
|
|
152
159
|
alias items line_items
|
|
160
|
+
alias discounts order_promotions
|
|
161
|
+
alias fulfillments shipments
|
|
162
|
+
alias_attribute :delivery_total, :shipment_total
|
|
163
|
+
alias display_delivery_total display_shipment_total
|
|
164
|
+
alias_attribute :fulfillment_status, :shipment_state
|
|
165
|
+
alias_attribute :payment_status, :payment_state
|
|
153
166
|
|
|
154
167
|
accepts_nested_attributes_for :line_items
|
|
155
168
|
accepts_nested_attributes_for :bill_address
|
|
156
169
|
accepts_nested_attributes_for :ship_address
|
|
170
|
+
alias shipping_address_attributes= ship_address_attributes=
|
|
171
|
+
alias billing_address_attributes= bill_address_attributes=
|
|
157
172
|
accepts_nested_attributes_for :payments, reject_if: :credit_card_nil_payment?
|
|
158
173
|
accepts_nested_attributes_for :shipments
|
|
159
174
|
|
|
@@ -220,8 +235,8 @@ module Spree
|
|
|
220
235
|
# shows completed orders first, by their completed_at date, then uncompleted orders by their created_at
|
|
221
236
|
scope :reverse_chronological, -> { order(Arel.sql('spree_orders.completed_at IS NULL'), completed_at: :desc, created_at: :desc) }
|
|
222
237
|
|
|
223
|
-
def self.
|
|
224
|
-
sanitized_query =
|
|
238
|
+
def self.search(query)
|
|
239
|
+
sanitized_query = sanitize_query_for_search(query)
|
|
225
240
|
return none if query.blank?
|
|
226
241
|
|
|
227
242
|
query_pattern = "%#{sanitized_query}%"
|
|
@@ -229,19 +244,22 @@ module Spree
|
|
|
229
244
|
conditions = []
|
|
230
245
|
conditions << arel_table[:number].lower.matches(query_pattern)
|
|
231
246
|
|
|
232
|
-
conditions <<
|
|
233
|
-
conditions <<
|
|
247
|
+
conditions << search_condition(Spree::Address, :firstname, sanitized_query)
|
|
248
|
+
conditions << search_condition(Spree::Address, :lastname, sanitized_query)
|
|
234
249
|
|
|
235
250
|
full_name = NameOfPerson::PersonName.full(sanitized_query)
|
|
236
251
|
|
|
237
252
|
if full_name.first.present? && full_name.last.present?
|
|
238
|
-
conditions <<
|
|
239
|
-
conditions <<
|
|
253
|
+
conditions << search_condition(Spree::Address, :firstname, full_name.first)
|
|
254
|
+
conditions << search_condition(Spree::Address, :lastname, full_name.last)
|
|
240
255
|
end
|
|
241
256
|
|
|
242
257
|
left_joins(:bill_address).where(arel_table[:email].lower.eq(query.downcase)).or(where(conditions.reduce(:or)))
|
|
243
258
|
end
|
|
244
259
|
|
|
260
|
+
# Backward compatibility alias — remove in Spree 6.0
|
|
261
|
+
def self.multi_search(query) = search(query)
|
|
262
|
+
|
|
245
263
|
# Find an order by prefixed ID first, falling back to number, then integer id for backwards compatibility
|
|
246
264
|
# @param param [String] the prefixed ID, number, or integer id to search for
|
|
247
265
|
# @return [Spree::Order, nil] the found order or nil
|
data/app/models/spree/product.rb
CHANGED
|
@@ -34,6 +34,7 @@ module Spree
|
|
|
34
34
|
include Spree::Metadata
|
|
35
35
|
include Spree::Product::Webhooks
|
|
36
36
|
include Spree::Product::Slugs
|
|
37
|
+
include Spree::SearchIndexable
|
|
37
38
|
if defined?(Spree::VendorConcern)
|
|
38
39
|
include Spree::VendorConcern
|
|
39
40
|
end
|
|
@@ -58,12 +59,6 @@ module Spree
|
|
|
58
59
|
|
|
59
60
|
self::Translation.class_eval do
|
|
60
61
|
normalizes :name, :meta_title, with: ->(value) { value&.to_s&.squish&.presence }
|
|
61
|
-
|
|
62
|
-
if defined?(PgSearch)
|
|
63
|
-
include PgSearch::Model
|
|
64
|
-
|
|
65
|
-
pg_search_scope :search_by_name, against: { name: 'A', meta_title: 'B' }, using: { trigram: { threshold: 0.3, word_similarity: true } }
|
|
66
|
-
end
|
|
67
62
|
end
|
|
68
63
|
|
|
69
64
|
# we need to have this callback before any dependent: :destroy associations
|
|
@@ -169,32 +164,15 @@ module Spree
|
|
|
169
164
|
where("#{Spree::Price.table_name}.compare_at_amount > #{Spree::Price.table_name}.amount")
|
|
170
165
|
}
|
|
171
166
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return none if query.blank?
|
|
167
|
+
scope :search, ->(query) {
|
|
168
|
+
next none if query.blank?
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
Spree::Product.search_by_name(query).ids
|
|
180
|
-
end
|
|
170
|
+
product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
|
|
171
|
+
where(id: product_ids.uniq.compact)
|
|
172
|
+
}
|
|
181
173
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
else
|
|
185
|
-
Spree::Variant.search_by_sku(query).pluck(:product_id)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
where(id: (product_ids + variant_product_ids).uniq.compact)
|
|
189
|
-
}
|
|
190
|
-
else
|
|
191
|
-
scope :multi_search, lambda { |query|
|
|
192
|
-
return none if query.blank?
|
|
193
|
-
|
|
194
|
-
product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
|
|
195
|
-
where(id: product_ids.uniq.compact)
|
|
196
|
-
}
|
|
197
|
-
end
|
|
174
|
+
# Backward compatibility alias — remove in Spree 6.0
|
|
175
|
+
scope :multi_search, ->(*args) { search(*args) }
|
|
198
176
|
|
|
199
177
|
scope :archivable, -> { where(status: %w[active draft]) }
|
|
200
178
|
scope :by_source, ->(source) { send(source) }
|
|
@@ -224,7 +202,7 @@ module Spree
|
|
|
224
202
|
shipping_category classifications option_types]
|
|
225
203
|
self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between
|
|
226
204
|
price_lte price_gte
|
|
227
|
-
multi_search in_stock out_of_stock with_option_value_ids
|
|
205
|
+
search multi_search in_stock out_of_stock with_option_value_ids
|
|
228
206
|
|
|
229
207
|
ascend_by_price descend_by_price]
|
|
230
208
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
class RefreshToken < Spree.base_class
|
|
3
|
+
has_prefix_id :rt
|
|
4
|
+
|
|
5
|
+
belongs_to :user, polymorphic: true
|
|
6
|
+
|
|
7
|
+
has_secure_token :token
|
|
8
|
+
|
|
9
|
+
validates :user, :expires_at, presence: true
|
|
10
|
+
|
|
11
|
+
scope :active, -> { where('expires_at > ?', Time.current) }
|
|
12
|
+
scope :expired, -> { where('expires_at <= ?', Time.current) }
|
|
13
|
+
|
|
14
|
+
def expired?
|
|
15
|
+
expires_at <= Time.current
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Rotate: destroy this token and create a new one.
|
|
19
|
+
# Returns the new token.
|
|
20
|
+
def rotate!(request_env: {})
|
|
21
|
+
new_token = nil
|
|
22
|
+
transaction do
|
|
23
|
+
new_token = self.class.create!(
|
|
24
|
+
user: user,
|
|
25
|
+
expires_at: self.class.default_expiry.from_now,
|
|
26
|
+
ip_address: request_env[:ip_address] || ip_address,
|
|
27
|
+
user_agent: request_env[:user_agent] || user_agent
|
|
28
|
+
)
|
|
29
|
+
destroy!
|
|
30
|
+
end
|
|
31
|
+
new_token
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create a refresh token for a user
|
|
35
|
+
def self.create_for(user, request_env: {})
|
|
36
|
+
create!(
|
|
37
|
+
user: user,
|
|
38
|
+
expires_at: default_expiry.from_now,
|
|
39
|
+
ip_address: request_env[:ip_address],
|
|
40
|
+
user_agent: request_env[:user_agent]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Revoke all refresh tokens for a user (e.g., on password change)
|
|
45
|
+
def self.revoke_all_for(user)
|
|
46
|
+
where(user: user).delete_all
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Clean up expired tokens
|
|
50
|
+
def self.cleanup_expired!
|
|
51
|
+
expired.delete_all
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.default_expiry
|
|
55
|
+
Spree::Api::Config[:refresh_token_expiry].seconds
|
|
56
|
+
rescue StandardError
|
|
57
|
+
30.days
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SearchProvider
|
|
3
|
+
class Base
|
|
4
|
+
attr_reader :store
|
|
5
|
+
|
|
6
|
+
# Whether this provider requires background indexing jobs.
|
|
7
|
+
# Override in subclasses. Database provider returns false.
|
|
8
|
+
def self.indexing_required?
|
|
9
|
+
false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(store)
|
|
13
|
+
@store = store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Search, filter, and return facets in one call.
|
|
17
|
+
#
|
|
18
|
+
# @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
|
|
19
|
+
# @param query [String, nil] text search query
|
|
20
|
+
# @param filters [Hash] structured filters (price_gte, with_option_value_ids, categories_id_eq, etc.)
|
|
21
|
+
# @param sort [String, nil] sort param (e.g. 'price', '-price', 'best_selling')
|
|
22
|
+
# @param page [Integer] page number
|
|
23
|
+
# @param limit [Integer] results per page
|
|
24
|
+
# @return [SearchResult]
|
|
25
|
+
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Index a product — called after product save. No-op for database provider.
|
|
30
|
+
#
|
|
31
|
+
# @param product [Spree::Product] the product to index
|
|
32
|
+
def index(product)
|
|
33
|
+
# no-op by default
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove a product from the index.
|
|
37
|
+
#
|
|
38
|
+
# @param product [Spree::Product] the product to remove
|
|
39
|
+
def remove(product)
|
|
40
|
+
# no-op by default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove a document from the index by prefixed ID (used when record is already deleted).
|
|
44
|
+
#
|
|
45
|
+
# @param prefixed_id [String] the prefixed ID (e.g. 'prod_abc')
|
|
46
|
+
def remove_by_id(prefixed_id)
|
|
47
|
+
# no-op by default
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Index a batch of documents. Called by rake task with pre-serialized documents.
|
|
51
|
+
#
|
|
52
|
+
# @param documents [Array<Hash>] serialized product documents
|
|
53
|
+
def index_batch(documents)
|
|
54
|
+
# no-op by default
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Configure index settings (filterable, sortable, searchable attributes).
|
|
58
|
+
# Called by rake task before indexing. No-op for database provider.
|
|
59
|
+
def ensure_index_settings!
|
|
60
|
+
# no-op by default
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bulk reindex — full catalog sync. Called manually or via rake task.
|
|
64
|
+
#
|
|
65
|
+
# @param scope [ActiveRecord::Relation] products to reindex (default: all in store)
|
|
66
|
+
def reindex(scope = nil)
|
|
67
|
+
# no-op by default
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def locale
|
|
73
|
+
Spree::Current.locale || store.default_market&.default_locale || I18n.locale.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def currency
|
|
77
|
+
Spree::Current.currency || store.default_market&.currency
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SearchProvider
|
|
3
|
+
class Database < Base
|
|
4
|
+
CUSTOM_SORT_SCOPES = {
|
|
5
|
+
'price' => :ascend_by_price,
|
|
6
|
+
'-price' => :descend_by_price,
|
|
7
|
+
'best_selling' => :by_best_selling
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
11
|
+
# 1. Text search
|
|
12
|
+
scope = scope.search(query) if query.present?
|
|
13
|
+
|
|
14
|
+
# 2. Extract internal params before passing to Ransack
|
|
15
|
+
category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
|
|
16
|
+
|
|
17
|
+
# 3. Structured filtering via Ransack
|
|
18
|
+
ransack_filters = sanitize_filters(filters)
|
|
19
|
+
if ransack_filters.present?
|
|
20
|
+
search = scope.ransack(ransack_filters)
|
|
21
|
+
scope = search.result(distinct: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 4. Facets (before sorting to avoid computed column conflicts with count)
|
|
25
|
+
filter_facets = build_facets(scope, category: category)
|
|
26
|
+
|
|
27
|
+
# 5. Total count (before sorting to avoid computed column conflicts with count)
|
|
28
|
+
total = scope.distinct.count
|
|
29
|
+
|
|
30
|
+
# 6. Sorting + pagination
|
|
31
|
+
scope = apply_sort(scope, sort)
|
|
32
|
+
page = [page.to_i, 1].max
|
|
33
|
+
limit = limit.to_i.clamp(1, 100)
|
|
34
|
+
products = scope.offset((page - 1) * limit).limit(limit)
|
|
35
|
+
|
|
36
|
+
SearchResult.new(
|
|
37
|
+
products: products,
|
|
38
|
+
filters: filter_facets[:filters],
|
|
39
|
+
sort_options: filter_facets[:sort_options],
|
|
40
|
+
default_sort: filter_facets[:default_sort],
|
|
41
|
+
total_count: total,
|
|
42
|
+
pagy: build_pagy(total, page, limit)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_pagy(count, page, limit)
|
|
49
|
+
require 'pagy'
|
|
50
|
+
Pagy::Offset.new(count: count, page: page, limit: limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_facets(scope, category: nil)
|
|
54
|
+
return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
|
|
55
|
+
|
|
56
|
+
Spree::Api::V3::FiltersAggregator.new(
|
|
57
|
+
scope: scope,
|
|
58
|
+
currency: currency,
|
|
59
|
+
category: category
|
|
60
|
+
).call
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sanitize_filters(filters)
|
|
64
|
+
return {} if filters.blank?
|
|
65
|
+
|
|
66
|
+
filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
|
|
67
|
+
filters.except('search', :search, '_category', :_category)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def apply_sort(scope, sort)
|
|
71
|
+
return scope if sort.blank?
|
|
72
|
+
|
|
73
|
+
scope_method = CUSTOM_SORT_SCOPES[sort]
|
|
74
|
+
if scope_method
|
|
75
|
+
scope.reorder(nil).send(scope_method)
|
|
76
|
+
else
|
|
77
|
+
# Standard Ransack sort: 'name' → 'name asc', '-name' → 'name desc'
|
|
78
|
+
ransack_sort = sort.split(',').map { |field|
|
|
79
|
+
if field.start_with?('-')
|
|
80
|
+
"#{field[1..]} desc"
|
|
81
|
+
else
|
|
82
|
+
"#{field} asc"
|
|
83
|
+
end
|
|
84
|
+
}.join(',')
|
|
85
|
+
|
|
86
|
+
scope.ransack(s: ransack_sort).result
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def available_sort_options
|
|
91
|
+
%w[price -price best_selling name -name -available_on available_on].map { |id| { id: id } }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|