spree_core 5.5.2 → 5.5.4

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: c50d137ef2f51b5fbdb5c343c360d805fe8839978386c08902ff3f29873c7e56
4
- data.tar.gz: f495c858e04f2c469323c744c5bcdff4c3d23891b85771f44082dc104922de21
3
+ metadata.gz: d1bfb936ec246953ec3805bd017b32594f42bb168c5373dd3ed86d4a219efd6d
4
+ data.tar.gz: 273695ba130dafae60be7bb880cb6bae32722d82bf8dc3d49ee11943049c1c65
5
5
  SHA512:
6
- metadata.gz: 96e1c8a7b981d143e5aab043ce6d236cd55627ddc28f1e29e573c873440e7515787e0f64f016409b54001c4313eca64b9f71f9f4641ddf3e021741c80300717d
7
- data.tar.gz: 6810ea91184c9646922ba3dba4973fd337dfc4068513ead2cc3d198bb5f316798f4f1810eb51b08b50381eecb8603c378d56c3ef5b162c38f6d0f1cb62caf519
6
+ metadata.gz: 412b093fc9621b695691bcbd3b3b9cb4335d42f17fe62b81b820ed67acabdabbc9b16954ae4331cac03d58fabb43217b72d68dd1bc4992d6ee13f203fcad4b71
7
+ data.tar.gz: 9e325093542d1a77466ff4780e61aae47d309daeba2f583723f00bb83c906f26ee085512b1bb85caae53d4c731f1a2ff309541878c675a2736f397e98931001d
@@ -1,6 +1,10 @@
1
1
  module Spree
2
2
  module Imports
3
3
  class ProcessGroupJob < Spree::Imports::BaseJob
4
+ # Rows are loaded in slices so a large group never holds every ImportRow
5
+ # (and its raw CSV data) in memory for the whole job.
6
+ ROWS_BATCH_SIZE = 100
7
+
4
8
  def perform(import_id, row_ids)
5
9
  import = Spree::Import.find(import_id)
6
10
  Spree::Current.store = import.store
@@ -8,16 +12,23 @@ module Spree
8
12
  mappings = import.mappings.mapped.to_a
9
13
  schema_fields = import.schema_fields
10
14
  large = import.large_import?
11
- # Skip rows already completed on a prior attempt so retries don't double-process them.
12
- rows = import.rows.where(id: row_ids).pending_and_failed.order(:row_number)
13
15
 
14
- if large
15
- Spree::Events.disable do
16
- rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
17
- end
18
- else
19
- rows.each do |row|
20
- row.process!(mappings: mappings, schema_fields: schema_fields)
16
+ row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
17
+ # Skip rows already completed on a prior attempt so retries don't double-process them.
18
+ rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
19
+ # Share the already-loaded import across rows: each row's processor reads
20
+ # `row.import` (store, ability, lookup cache), and without this every row
21
+ # lazily loads its own Import instance and rebuilds all of that per row.
22
+ rows.each { |row| row.association(:import).target = import }
23
+
24
+ if large
25
+ Spree::Events.disable do
26
+ rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
27
+ end
28
+ else
29
+ rows.each do |row|
30
+ row.process!(mappings: mappings, schema_fields: schema_fields)
31
+ end
21
32
  end
22
33
  end
23
34
 
@@ -2,6 +2,7 @@ module Spree
2
2
  module Imports
3
3
  class ProcessRowsJob < Spree::Imports::BaseJob
4
4
  BATCH_SIZE = 100
5
+ UNGROUPED_KEY = '__ungrouped__'.freeze
5
6
 
6
7
  def perform(import_id)
7
8
  import = Spree::Import.find(import_id)
@@ -27,20 +28,27 @@ module Spree
27
28
 
28
29
  import.rows.pending_and_failed.order(:row_number).pluck(:id, :data).each do |id, data|
29
30
  parsed = JSON.parse(data)
30
- key = parsed[file_column].to_s.strip.downcase.presence || '__ungrouped__'
31
+ key = parsed[file_column].to_s.strip.downcase.presence || UNGROUPED_KEY
31
32
  groups[key] << id
32
33
  rescue JSON::ParserError
33
- groups['__ungrouped__'] << id
34
+ groups[UNGROUPED_KEY] << id
34
35
  end
35
36
 
37
+ # Rows without a group value don't depend on each other, so they don't have
38
+ # to share a single job — split them into bounded batches. Real groups stay
39
+ # intact: their rows must run sequentially (product row before variant rows).
40
+ ungrouped = groups.delete(UNGROUPED_KEY)
41
+ batches = groups.values
42
+ ungrouped&.each_slice(BATCH_SIZE) { |row_ids| batches << row_ids }
43
+
36
44
  # Set count before enqueuing so workers can't complete prematurely
37
45
  import.update_columns(
38
- processing_groups_count: groups.size,
46
+ processing_groups_count: batches.size,
39
47
  completed_groups_count: 0,
40
48
  updated_at: Time.current
41
49
  )
42
50
 
43
- groups.each_value { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
51
+ batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
44
52
  end
45
53
 
46
54
  def dispatch_batched(import)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Keeps single-use credentials out of the persisted webhook delivery log.
5
+ #
6
+ # Some events must carry a live credential to their subscriber — a storefront
7
+ # that owns its transactional emails needs the real password reset token to
8
+ # build the email. Delivering it over TLS to a merchant-configured endpoint is
9
+ # intended; keeping a readable copy in `spree_webhook_deliveries.payload` is
10
+ # not, because that column is queryable and is served back through the Admin
11
+ # API delivery log.
12
+ #
13
+ # Sensitive values are therefore replaced with {REDACTION_PLACEHOLDER} before
14
+ # the record is written, and re-attached in memory at send time so the
15
+ # outgoing request body is unchanged.
16
+ module WebhookPayloadRedaction
17
+ extend ActiveSupport::Concern
18
+
19
+ # Payload keys under `data` whose values are live credentials.
20
+ SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token].freeze
21
+
22
+ REDACTION_PLACEHOLDER = '[REDACTED]'
23
+
24
+ # Splits a payload into the version safe to persist and the secrets held
25
+ # back from it.
26
+ #
27
+ # @param payload [Hash] the full event payload
28
+ # @return [Array(Hash, Hash)] redacted payload, and the extracted secrets
29
+ # keyed as they appeared under `data`
30
+ def self.split(payload)
31
+ secrets = {}
32
+
33
+ redacted = transform_data_hashes(payload) do |data|
34
+ data.to_h do |key, value|
35
+ if SENSITIVE_PAYLOAD_KEYS.include?(key.to_s) && value.present?
36
+ secrets[secret_key_for(key)] = value
37
+ [key, REDACTION_PLACEHOLDER]
38
+ else
39
+ [key, value]
40
+ end
41
+ end
42
+ end
43
+
44
+ secrets.empty? ? [payload, {}] : [redacted, secrets]
45
+ end
46
+
47
+ # Re-attaches previously extracted secrets to a redacted payload.
48
+ #
49
+ # @param payload [Hash] the redacted payload
50
+ # @param secrets [Hash] secrets returned by {split}
51
+ # @return [Hash] the payload as it should go over the wire
52
+ def self.merge(payload, secrets)
53
+ return payload if secrets.blank?
54
+
55
+ transform_data_hashes(payload) do |data|
56
+ data.to_h do |key, value|
57
+ secret = secrets[secret_key_for(key)]
58
+ [key, secret.presence || value]
59
+ end
60
+ end
61
+ end
62
+
63
+ # Applies +block+ to every `data` hash on the payload.
64
+ #
65
+ # Both key forms are visited rather than only the first match: a payload
66
+ # carrying `:data` *and* `'data'` would otherwise leave one of them
67
+ # unredacted.
68
+ def self.transform_data_hashes(payload)
69
+ return payload unless payload.is_a?(Hash)
70
+
71
+ [:data, 'data'].reduce(payload) do |result, data_key|
72
+ data = result[data_key]
73
+ next result unless data.is_a?(Hash)
74
+
75
+ result.merge(data_key => yield(data))
76
+ end
77
+ end
78
+ private_class_method :transform_data_hashes
79
+
80
+ # Secrets are keyed by name alone. They cross an ActiveJob serialization
81
+ # boundary, which coerces symbol keys to strings, so the key form at split
82
+ # time cannot be relied on to still match at merge time.
83
+ def self.secret_key_for(key)
84
+ key.to_s
85
+ end
86
+ private_class_method :secret_key_for
87
+ end
88
+ end
@@ -245,6 +245,15 @@ module Spree
245
245
  @current_ability ||= Spree.ability_class.new(user, { store: store })
246
246
  end
247
247
 
248
+ # Per-instance cache shared by row processors within a single processing job.
249
+ # Group jobs funnel every row through the same Import instance, so lookups of
250
+ # shared records (tax/shipping categories, option types, metafield definitions)
251
+ # resolve once per job instead of once per row.
252
+ # @return [Hash]
253
+ def row_lookup_cache
254
+ @row_lookup_cache ||= {}
255
+ end
256
+
248
257
  def event_serializer_class
249
258
  'Spree::Api::V3::ImportSerializer'.safe_constantize
250
259
  end
@@ -7,6 +7,11 @@ module Spree
7
7
  belongs_to :webhook_endpoint, class_name: 'Spree::WebhookEndpoint'
8
8
  delegate :url, to: :webhook_endpoint
9
9
 
10
+ # Credentials stripped from the persisted payload. Never written to the
11
+ # database — held in memory on a single delivery instance, assigned by
12
+ # WebhookDeliveryJob when it runs. See {Spree::WebhookPayloadRedaction}.
13
+ attr_accessor :payload_secrets
14
+
10
15
  validates :event_name, presence: true
11
16
  validates :payload, presence: true
12
17
 
@@ -64,6 +69,23 @@ module Spree
64
69
  webhook_endpoint.check_auto_disable! unless is_success
65
70
  end
66
71
 
72
+ # Payload as it should be sent over the wire.
73
+ #
74
+ # Re-attaches any credentials withheld from the persisted column. Once this
75
+ # record has been reloaded from the database those secrets are gone for
76
+ # good, so a redelivery sends the redacted placeholder — single-use tokens
77
+ # are not replayable anyway.
78
+ #
79
+ # This is deliberate: keeping a recoverable copy would put the credential
80
+ # back at rest, which is what redaction exists to prevent. If a delivery is
81
+ # lost (worker crash between creation and delivery), the customer requests
82
+ # a new reset, which mints a fresh token.
83
+ #
84
+ # @return [Hash]
85
+ def deliverable_payload
86
+ Spree::WebhookPayloadRedaction.merge(payload, payload_secrets)
87
+ end
88
+
67
89
  # Create a new delivery with the same payload and queue it.
68
90
  # Used to retry failed deliveries manually.
69
91
  #
@@ -3,6 +3,22 @@ require 'nokogiri'
3
3
  module Spree
4
4
  module DataFeeds
5
5
  class GooglePresenter < BasePresenter
6
+ # Optional Google Merchant Center product attributes sourced from
7
+ # metafields. See https://support.google.com/merchants/answer/7052112
8
+ OPTIONAL_ATTRIBUTES = %w[
9
+ brand gtin mpn identifier_exists condition adult multipack is_bundle
10
+ age_group color gender material pattern size size_type size_system
11
+ product_length product_width product_height product_weight
12
+ google_product_category product_type sale_price sale_price_effective_date
13
+ cost_of_goods_sold unit_pricing_measure unit_pricing_base_measure
14
+ shipping shipping_label shipping_weight shipping_length shipping_width
15
+ shipping_height ships_from_country transit_time_label max_handling_time
16
+ min_handling_time tax tax_category energy_efficiency_class
17
+ min_energy_efficiency_class max_energy_efficiency_class
18
+ gtin_source expiration_date custom_label_0 custom_label_1 custom_label_2
19
+ custom_label_3 custom_label_4 mobile_link additional_image_link
20
+ ].freeze
21
+
6
22
  # @return [String] RSS XML feed for Google Merchant Center
7
23
  def call
8
24
  builder = Nokogiri::XML::Builder.new do |xml|
@@ -59,10 +75,21 @@ module Spree
59
75
 
60
76
  def build_optional_attributes(xml, product)
61
77
  product.public_metafields.each do |metafield|
62
- xml['g'].send(metafield.metafield_definition.key.parameterize.underscore, metafield.value)
78
+ key = metafield.metafield_definition.key.parameterize.underscore
79
+ next unless OPTIONAL_ATTRIBUTES.include?(key)
80
+
81
+ append_g_element(xml, key, metafield.value)
63
82
  end
64
83
  end
65
84
 
85
+ def append_g_element(xml, name, value)
86
+ parent = xml.parent
87
+ node = Nokogiri::XML::Node.new(name, parent.document)
88
+ node.namespace = parent.namespace_scopes.find { |ns| ns.prefix == 'g' }
89
+ node.content = value
90
+ parent << node
91
+ end
92
+
66
93
  def format_title(product, variant)
67
94
  parts = [product.name]
68
95
  variant.option_values.each do |option_value|
@@ -20,6 +20,15 @@ module Spree
20
20
 
21
21
  private
22
22
 
23
+ # Memoizes a shared-record lookup (including nil misses) in the import's
24
+ # per-job cache so repeated rows don't re-run the same query.
25
+ def cached_lookup(*key)
26
+ cache = import.row_lookup_cache
27
+ return cache[key] if cache.key?(key)
28
+
29
+ cache[key] = yield
30
+ end
31
+
23
32
  def build_schema_hash(row, mappings, schema_fields)
24
33
  attributes = {}
25
34
  schema_fields.each do |field|
@@ -123,12 +123,16 @@ module Spree
123
123
 
124
124
  def prepare_shipping_category
125
125
  shipping_category_name = attributes['shipping_category'].strip
126
- Spree::ShippingCategory.find_by(name: shipping_category_name)
126
+ cached_lookup(:shipping_category, shipping_category_name) do
127
+ Spree::ShippingCategory.find_by(name: shipping_category_name)
128
+ end
127
129
  end
128
130
 
129
131
  def prepare_tax_category
130
132
  tax_category_name = attributes['tax_category'].strip
131
- Spree::TaxCategory.find_by(name: tax_category_name)
133
+ cached_lookup(:tax_category, tax_category_name) do
134
+ Spree::TaxCategory.find_by(name: tax_category_name)
135
+ end
132
136
  end
133
137
 
134
138
  def prepare_option_value_variants
@@ -171,27 +175,39 @@ module Spree
171
175
  # surfaces via the DB unique index (RecordNotUnique) or the AR uniqueness
172
176
  # validator (RecordInvalid with a :taken error on the relevant attribute).
173
177
  def find_or_create_option_type!(presentation)
174
- Spree::OptionType.search_by_name(presentation).first || Spree::OptionType.create!(presentation: presentation)
175
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
176
- raise unless uniqueness_conflict?(e, :name)
178
+ cached_lookup(:option_type, presentation) do
179
+ begin
180
+ Spree::OptionType.search_by_name(presentation).first || Spree::OptionType.create!(presentation: presentation)
181
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
182
+ raise unless uniqueness_conflict?(e, :name)
177
183
 
178
- Spree::OptionType.search_by_name(presentation).first!
184
+ Spree::OptionType.search_by_name(presentation).first!
185
+ end
186
+ end
179
187
  end
180
188
 
181
189
  def find_or_create_option_value!(option_type, presentation)
182
- option_type.option_values.search_by_name(presentation).first || option_type.option_values.create!(presentation: presentation)
183
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
184
- raise unless uniqueness_conflict?(e, :name)
190
+ cached_lookup(:option_value, option_type.id, presentation) do
191
+ begin
192
+ option_type.option_values.search_by_name(presentation).first || option_type.option_values.create!(presentation: presentation)
193
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
194
+ raise unless uniqueness_conflict?(e, :name)
185
195
 
186
- option_type.option_values.search_by_name(presentation).first!
196
+ option_type.option_values.search_by_name(presentation).first!
197
+ end
198
+ end
187
199
  end
188
200
 
189
201
  def find_or_create_product_option_type!(option_type)
190
- Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
191
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
192
- raise unless uniqueness_conflict?(e, :product_id)
202
+ cached_lookup(:product_option_type, product.id, option_type.id) do
203
+ begin
204
+ Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
205
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
206
+ raise unless uniqueness_conflict?(e, :product_id)
193
207
 
194
- Spree::ProductOptionType.find_by!(product: product, option_type: option_type)
208
+ Spree::ProductOptionType.find_by!(product: product, option_type: option_type)
209
+ end
210
+ end
195
211
  end
196
212
 
197
213
  # RecordNotUnique is always a uniqueness conflict; RecordInvalid only when the
@@ -245,11 +261,13 @@ module Spree
245
261
  namespace, key = product.extract_namespace_and_key(full_key)
246
262
 
247
263
  # Find or initialize metafield definition
248
- metafield_definition = Spree::MetafieldDefinition.find_by(
249
- namespace: namespace,
250
- key: key,
251
- resource_type: product.class.name
252
- )
264
+ metafield_definition = cached_lookup(:metafield_definition, namespace, key, product.class.name) do
265
+ Spree::MetafieldDefinition.find_by(
266
+ namespace: namespace,
267
+ key: key,
268
+ resource_type: product.class.name
269
+ )
270
+ end
253
271
 
254
272
  next unless metafield_definition
255
273
 
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.5.2'.freeze
2
+ VERSION = '5.5.4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -256,7 +256,7 @@ module Spree
256
256
  @@stock_item_attributes = [:variant_id, :stock_location_id, :backorderable, :count_on_hand, { metadata: {} }]
257
257
 
258
258
  @@stock_location_attributes = [
259
- :name, :active, :address1, :address2, :city, :zipcode, :company,
259
+ :name, :admin_name, :active, :address1, :address2, :city, :zipcode, :company,
260
260
  :backorderable_default, :state_name, :state_id, :country_id, :phone,
261
261
  :propagate_all_variants
262
262
  ]
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.5.2
4
+ version: 5.5.4
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-07-06 00:00:00.000000000 Z
13
+ date: 2026-07-20 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -930,6 +930,7 @@ files:
930
930
  - app/models/concerns/spree/user_reporting.rb
931
931
  - app/models/concerns/spree/user_roles.rb
932
932
  - app/models/concerns/spree/vat_price_calculation.rb
933
+ - app/models/concerns/spree/webhook_payload_redaction.rb
933
934
  - app/models/spree/ability.rb
934
935
  - app/models/spree/address.rb
935
936
  - app/models/spree/adjustable/adjuster/base.rb
@@ -1818,9 +1819,9 @@ licenses:
1818
1819
  - BSD-3-Clause
1819
1820
  metadata:
1820
1821
  bug_tracker_uri: https://github.com/spree/spree/issues
1821
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.2
1822
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.4
1822
1823
  documentation_uri: https://docs.spreecommerce.org/
1823
- source_code_uri: https://github.com/spree/spree/tree/v5.5.2
1824
+ source_code_uri: https://github.com/spree/spree/tree/v5.5.4
1824
1825
  post_install_message:
1825
1826
  rdoc_options: []
1826
1827
  require_paths: