spree_core 5.6.0 → 5.6.1

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/imports/process_group_job.rb +45 -26
  3. data/app/jobs/spree/imports/process_rows_job.rb +10 -2
  4. data/app/models/concerns/spree/metafield_filterable.rb +162 -0
  5. data/app/models/concerns/spree/stores/setup.rb +6 -1
  6. data/app/models/spree/base.rb +35 -0
  7. data/app/models/spree/channel/gating.rb +4 -4
  8. data/app/models/spree/export.rb +14 -6
  9. data/app/models/spree/import.rb +92 -0
  10. data/app/models/spree/metafield.rb +11 -0
  11. data/app/models/spree/metafield_definition/search_capabilities.rb +104 -0
  12. data/app/models/spree/metafield_definition.rb +2 -1
  13. data/app/models/spree/metafields/long_text.rb +4 -0
  14. data/app/models/spree/metafields/number.rb +8 -0
  15. data/app/models/spree/metafields/short_text.rb +8 -0
  16. data/app/models/spree/product.rb +19 -1
  17. data/app/models/spree/report.rb +11 -1
  18. data/app/models/spree/search_provider/base.rb +5 -0
  19. data/app/models/spree/search_provider/database.rb +49 -3
  20. data/app/models/spree/search_provider/meilisearch.rb +63 -8
  21. data/app/presenters/spree/search_provider/product_presenter.rb +21 -1
  22. data/app/services/spree/imports/row_processors/product_variant.rb +6 -1
  23. data/app/services/spree/sample_data/helper.rb +55 -0
  24. data/app/services/spree/sample_data/import_builder.rb +32 -0
  25. data/app/services/spree/sample_data/import_runner.rb +23 -42
  26. data/app/services/spree/sample_data/loader.rb +6 -57
  27. data/app/services/spree/search_provider/metafield_schema.rb +144 -0
  28. data/app/services/spree/seeds/allowed_origins.rb +3 -4
  29. data/app/services/spree/seeds/states.rb +35 -16
  30. data/config/locales/en.yml +6 -0
  31. data/db/migrate/20260722000001_add_searchable_sortable_to_spree_metafield_definitions.rb +6 -0
  32. data/db/migrate/20260727000001_add_unique_country_abbr_index_to_spree_states.rb +30 -0
  33. data/lib/spree/core/preferences/preferable.rb +7 -1
  34. data/lib/spree/core/version.rb +1 -1
  35. data/lib/spree/permitted_attributes.rb +5 -1
  36. data/lib/spree/testing_support/factories/metafield_definition_factory.rb +10 -0
  37. data/lib/tasks/product_tag_tenants.rake +1 -3
  38. metadata +11 -4
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module SearchProvider
5
+ # Product metafield definitions that participate in search/sort.
6
+ class MetafieldSchema
7
+ # Ransack-style predicates accepted per field type. Mirrors the operator
8
+ # sets the admin dashboard's filter panel offers for string/number columns
9
+ # (see packages/dashboard-core/src/components/table-toolbar.tsx).
10
+ TEXT_FILTER_PREDICATES = %w[i_cont cont eq not_eq start end present blank].freeze
11
+ NUMBER_FILTER_PREDICATES = %w[eq gt gteq lt lteq present blank].freeze
12
+
13
+ # Longest first so a key ending in +_i_cont+ isn't mis-split as the
14
+ # shorter +_cont+ against a definition keyed +..._i+.
15
+ ORDERED_FILTER_PREDICATES = (TEXT_FILTER_PREDICATES | NUMBER_FILTER_PREDICATES).
16
+ sort_by { |predicate| -predicate.length }.freeze
17
+
18
+ # @return [Hash{String => Spree::MetafieldDefinition}]
19
+ def entries
20
+ @entries ||= product_definitions.index_by(&:filter_key)
21
+ end
22
+
23
+ # @return [Array<String>]
24
+ def searchable_attribute_keys
25
+ @searchable_attribute_keys ||= entries.each_value.filter_map do |definition|
26
+ definition.filter_key if definition.searchable?
27
+ end
28
+ end
29
+
30
+ # @return [Array<String>]
31
+ def sortable_attribute_keys
32
+ @sortable_attribute_keys ||= entries.each_value.filter_map do |definition|
33
+ definition.filter_key if definition.sortable?
34
+ end
35
+ end
36
+
37
+ # @param attribute_key [String]
38
+ # @return [Spree::MetafieldDefinition, nil]
39
+ def entry_for(attribute_key)
40
+ entries[attribute_key.to_s]
41
+ end
42
+
43
+ # Every schema attribute accepts filters — values are already indexed
44
+ # for search/sort, so filtering rides on the same document keys.
45
+ #
46
+ # @return [Array<String>]
47
+ def filterable_attribute_keys
48
+ @filterable_attribute_keys ||= entries.keys
49
+ end
50
+
51
+ # @return [Array<String>]
52
+ def sort_ids
53
+ @sort_ids ||= sortable_attribute_keys.flat_map { |key| [key, "-#{key}"] }
54
+ end
55
+
56
+ # @return [Array<Hash>] +{ id:, label: }+
57
+ def sort_options
58
+ @sort_options ||= entries.each_value.select(&:sortable?).flat_map do |definition|
59
+ key = definition.filter_key
60
+ name = definition.name.presence || key
61
+
62
+ [
63
+ { id: key, label: sort_option_label(name, definition.field_type, :asc) },
64
+ { id: "-#{key}", label: sort_option_label(name, definition.field_type, :desc) }
65
+ ]
66
+ end
67
+ end
68
+
69
+ # @param sort [String, nil]
70
+ # @return [Hash, nil] { attribute:, direction: 'asc'|'desc' }
71
+ def parse_sort(sort)
72
+ value = sort.to_s.strip
73
+ return if value.blank?
74
+
75
+ attribute = value.delete_prefix('-')
76
+ definition = entry_for(attribute)
77
+ return unless definition&.sortable?
78
+
79
+ {
80
+ attribute: attribute,
81
+ direction: value.start_with?('-') ? 'desc' : 'asc'
82
+ }
83
+ end
84
+
85
+ # Splits a Ransack-style predicate key (e.g. +cf_custom_label_i_cont+)
86
+ # into the definition it targets and the predicate. Both search keys and
87
+ # predicates contain underscores, so predicates are tried longest-first —
88
+ # otherwise +cf_x_i_cont+ would resolve as key +x_i+ with predicate
89
+ # +cont+. A key that itself ends in a predicate name (+..._eq_eq+) is
90
+ # only reachable if the shorter split names a real definition.
91
+ #
92
+ # @param key [String, Symbol]
93
+ # @return [Hash, nil] +{ definition:, predicate: }+
94
+ def parse_filter(key)
95
+ value = key.to_s
96
+ return unless value.start_with?('cf_')
97
+
98
+ ORDERED_FILTER_PREDICATES.each do |predicate|
99
+ next unless value.end_with?("_#{predicate}")
100
+
101
+ definition = entries[value.delete_suffix("_#{predicate}")]
102
+ next unless definition && filter_predicates_for(definition.field_type).include?(predicate)
103
+
104
+ return { definition: definition, predicate: predicate }
105
+ end
106
+
107
+ nil
108
+ end
109
+
110
+ # @param field_type [String]
111
+ # @return [Array<String>]
112
+ def filter_predicates_for(field_type)
113
+ field_type == 'number' ? NUMBER_FILTER_PREDICATES : TEXT_FILTER_PREDICATES
114
+ end
115
+
116
+ # Stamp for Store API filters cache keys.
117
+ #
118
+ # @return [String]
119
+ def schema_version
120
+ @schema_version ||= [
121
+ entries.size,
122
+ entries.each_value.filter_map(&:updated_at).max&.utc&.iso8601(6)
123
+ ].join('-')
124
+ end
125
+
126
+ private
127
+
128
+ def product_definitions
129
+ scope = Spree::MetafieldDefinition.for_resource_type('Spree::Product')
130
+ scope.where(searchable: true).or(scope.where(sortable: true))
131
+ end
132
+
133
+ def sort_option_label(name, field_type, direction)
134
+ suffix =
135
+ if field_type == 'number'
136
+ direction == :asc ? Spree.t(:sort_low_to_high) : Spree.t(:sort_high_to_low)
137
+ else
138
+ direction == :asc ? Spree.t(:sort_a_to_z) : Spree.t(:sort_z_to_a)
139
+ end
140
+ "#{name} (#{suffix})"
141
+ end
142
+ end
143
+ end
144
+ end
@@ -4,10 +4,9 @@ module Spree
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
6
  def call
7
- store = Spree::Store.default
8
- return unless store&.persisted?
9
-
10
- store.allowed_origins.find_or_create_by!(origin: 'http://localhost')
7
+ Spree::Store.all.each do |store|
8
+ store.allowed_origins.find_or_create_by!(origin: 'http://localhost')
9
+ end
11
10
  end
12
11
  end
13
12
  end
@@ -10,14 +10,18 @@ module Spree
10
10
 
11
11
  def call
12
12
  Spree::Country.where(states_required: true).each do |country|
13
- carmen_country = Carmen::Country.named(country.name)
13
+ # Match on ISO code, not name: Spree stores official ISO-3166 names
14
+ # ("United States of America") while Carmen uses common ones
15
+ # ("United States"), and `named` is an exact match — so a name lookup
16
+ # silently skipped the US, leaving it with no states at all.
17
+ carmen_country = Carmen::Country.coded(country.iso) || Carmen::Country.named(country.name)
14
18
  next unless carmen_country
15
19
 
16
- carmen_country.subregions.each do |subregion|
20
+ states = carmen_country.subregions.flat_map do |subregion|
17
21
  if carmen_country.alpha_2_code == 'US'
18
22
  # Produces 50 states, one postal district (Washington DC)
19
23
  # and 3 APO's as you would expect to see on any good U.S. states list.
20
- next if EXCLUDED_US_STATES.include?(subregion.code)
24
+ next [] if EXCLUDED_US_STATES.include?(subregion.code)
21
25
 
22
26
  state_level(country, subregion)
23
27
  elsif carmen_country.alpha_2_code == 'CA' || carmen_country.alpha_2_code == 'MX'
@@ -27,7 +31,7 @@ module Spree
27
31
  elsif carmen_country.alpha_2_code == 'CN'
28
32
  # Removes 3 "States" from that list that are also listed as Countries,
29
33
  # Hong Kong, Taiwan and Macao
30
- next if EXCLUDED_CN_STATES.include?(subregion.code)
34
+ next [] if EXCLUDED_CN_STATES.include?(subregion.code)
31
35
 
32
36
  state_level(country, subregion)
33
37
  elsif subregion.subregions?
@@ -36,27 +40,42 @@ module Spree
36
40
  state_level(country, subregion)
37
41
  end
38
42
  end
43
+
44
+ # One upsert per country rather than one per subregion.
45
+ upsert_states(states)
39
46
  end
40
47
  end
41
48
 
42
49
  protected
43
50
 
44
51
  def state_level(country, subregion)
45
- country.states.where(
46
- name: subregion.name,
47
- abbr: subregion.code
48
- ).first_or_create!
52
+ [{ name: subregion.name, abbr: subregion.code, country_id: country.id }]
49
53
  end
50
54
 
51
55
  def province_level(country, subregion)
52
- new_states = subregion.subregions.map do |province|
53
- Hash[
54
- name: province.name,
55
- abbr: province.code,
56
- country_id: country.id
57
- ]
58
- end.compact.uniq
59
- Spree::State.insert_all(new_states)
56
+ subregion.subregions.map do |province|
57
+ { name: province.name, abbr: province.code, country_id: country.id }
58
+ end
59
+ end
60
+
61
+ # Seeds are re-run on every deploy, so this has to converge rather than
62
+ # duplicate: `[country_id, abbr]` is unique, and a name change upstream
63
+ # updates the existing row instead of adding a second one.
64
+ def upsert_states(states)
65
+ states = states.uniq { |state| [state[:country_id], state[:abbr]] }.reject { |state| state[:abbr].blank? }
66
+ return if states.empty?
67
+
68
+ now = Time.current
69
+ opts = { update_only: %i[name] }
70
+ # MySQL infers the conflict target from the table's unique constraints
71
+ # and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
72
+ opts[:unique_by] = %i[country_id abbr] unless mysql_adapter?
73
+
74
+ Spree::State.upsert_all(states.map { |state| state.merge(created_at: now, updated_at: now) }, **opts)
75
+ end
76
+
77
+ def mysql_adapter?
78
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql')
60
79
  end
61
80
  end
62
81
  end
@@ -71,6 +71,8 @@ en:
71
71
  quantity: Quantity
72
72
  spree/metafield_definition:
73
73
  namespace: Namespace
74
+ searchable: Searchable
75
+ sortable: Sortable
74
76
  spree/option_type:
75
77
  filterable: Filterable
76
78
  kind: Kind
@@ -1479,7 +1481,11 @@ en:
1479
1481
  skus: SKUs
1480
1482
  slug: Slug
1481
1483
  social: Social
1484
+ sort_a_to_z: A-Z
1482
1485
  sort_by: Sort by
1486
+ sort_high_to_low: high-low
1487
+ sort_low_to_high: low-high
1488
+ sort_z_to_a: Z-A
1483
1489
  source: Source
1484
1490
  special_instructions: Special Instructions
1485
1491
  split: Split
@@ -0,0 +1,6 @@
1
+ class AddSearchableSortableToSpreeMetafieldDefinitions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :spree_metafield_definitions, :searchable, :boolean, if_not_exists: true
4
+ add_column :spree_metafield_definitions, :sortable, :boolean, if_not_exists: true
5
+ end
6
+ end
@@ -0,0 +1,30 @@
1
+ class AddUniqueCountryAbbrIndexToSpreeStates < ActiveRecord::Migration[7.2]
2
+ def up
3
+ remove_duplicate_states!
4
+
5
+ add_index :spree_states, [:country_id, :abbr],
6
+ unique: true,
7
+ name: 'index_spree_states_on_country_id_and_abbr'
8
+ end
9
+
10
+ def down
11
+ remove_index :spree_states, name: 'index_spree_states_on_country_id_and_abbr'
12
+ end
13
+
14
+ private
15
+
16
+ # `Spree::Seeds::States` used a plain `insert_all` for province-level
17
+ # countries, so re-running the seeds duplicated their states. Keep the oldest
18
+ # row of each group — addresses point at it — and drop the rest.
19
+ def remove_duplicate_states!
20
+ duplicated = Spree::State.group(:country_id, :abbr).having('COUNT(*) > 1').pluck(:country_id, :abbr)
21
+
22
+ duplicated.each do |country_id, abbr|
23
+ ids = Spree::State.where(country_id: country_id, abbr: abbr).order(:id).pluck(:id)
24
+ keeper = ids.shift
25
+
26
+ Spree::Address.where(state_id: ids).update_all(state_id: keeper) if ids.any?
27
+ Spree::State.where(id: ids).delete_all
28
+ end
29
+ end
30
+ end
@@ -123,7 +123,13 @@ module Spree::Preferences::Preferable
123
123
  def convert_preference_value(value, type, nullable: false)
124
124
  case type
125
125
  when :string, :text
126
- value.to_s
126
+ # A nullable string keeps "unset" (nil / empty string) as nil so it can
127
+ # fall back to another value, instead of collapsing it to "".
128
+ if nullable && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
129
+ nil
130
+ else
131
+ value.to_s
132
+ end
127
133
  when :password
128
134
  value.to_s
129
135
  when :decimal
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.6.0'.freeze
2
+ VERSION = '5.6.1'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -152,7 +152,11 @@ module Spree
152
152
 
153
153
  @@metafield_attributes = [:id, :value, :type, :metafield_definition_id, :_destroy]
154
154
 
155
- @@metafield_definition_attributes = [:key, :name, :namespace, :metafield_type, :resource_type, :display_on]
155
+ @@metafield_definition_attributes = [
156
+ :key, :name, :namespace, :metafield_type, :resource_type, :display_on,
157
+ :searchable, :sortable
158
+ ]
159
+
156
160
 
157
161
  @@option_type_attributes = [:name, :presentation, :position, :filterable, :kind,
158
162
  option_values_attributes: [:id, :name, :presentation, :position, :color_code, :image, :_destroy]]
@@ -6,6 +6,8 @@ FactoryBot.define do
6
6
  metafield_type { 'Spree::Metafields::ShortText' }
7
7
  resource_type { 'Spree::Product' }
8
8
  display_on { 'both' }
9
+ searchable { false }
10
+ sortable { false }
9
11
 
10
12
  trait :front_end_only do
11
13
  display_on { 'front_end' }
@@ -51,6 +53,14 @@ FactoryBot.define do
51
53
  name { 'Metadata' }
52
54
  end
53
55
 
56
+ trait :searchable do
57
+ searchable { true }
58
+ end
59
+
60
+ trait :sortable do
61
+ sortable { true }
62
+ end
63
+
54
64
  trait :for_variant do
55
65
  resource_type { 'Spree::Variant' }
56
66
  key { 'variant_custom' }
@@ -14,11 +14,9 @@ namespace :spree do
14
14
  task backfill_product_tag_tenants: :environment do
15
15
  taggings = ActsAsTaggableOn::Tagging.arel_table.name
16
16
  products = Spree::Product.table_name
17
-
18
17
  updated = ActsAsTaggableOn::Tagging.
19
18
  where(taggable_type: 'Spree::Product', tenant: nil).
20
- joins("INNER JOIN #{products} ON #{products}.id = #{taggings}.taggable_id").
21
- update_all("tenant = #{products}.store_id")
19
+ update_all("tenant = (SELECT store_id FROM #{products} WHERE #{products}.id = #{taggings}.taggable_id)")
22
20
 
23
21
  puts " Backfilled tenant on #{updated} product tagging(s)."
24
22
  end
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.6.0
4
+ version: 5.6.1
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-23 00:00:00.000000000 Z
13
+ date: 2026-07-28 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -904,6 +904,7 @@ files:
904
904
  - app/models/concerns/spree/legacy_multi_store_support.rb
905
905
  - app/models/concerns/spree/memoized_data.rb
906
906
  - app/models/concerns/spree/metadata.rb
907
+ - app/models/concerns/spree/metafield_filterable.rb
907
908
  - app/models/concerns/spree/metafields.rb
908
909
  - app/models/concerns/spree/multi_searchable.rb
909
910
  - app/models/concerns/spree/named_type.rb
@@ -1035,6 +1036,7 @@ files:
1035
1036
  - app/models/spree/market_country.rb
1036
1037
  - app/models/spree/metafield.rb
1037
1038
  - app/models/spree/metafield_definition.rb
1039
+ - app/models/spree/metafield_definition/search_capabilities.rb
1038
1040
  - app/models/spree/metafields/boolean.rb
1039
1041
  - app/models/spree/metafields/json.rb
1040
1042
  - app/models/spree/metafields/long_text.rb
@@ -1344,8 +1346,11 @@ files:
1344
1346
  - app/services/spree/prices/bulk_upsert.rb
1345
1347
  - app/services/spree/products/auto_match_taxons.rb
1346
1348
  - app/services/spree/products/duplicator.rb
1349
+ - app/services/spree/sample_data/helper.rb
1350
+ - app/services/spree/sample_data/import_builder.rb
1347
1351
  - app/services/spree/sample_data/import_runner.rb
1348
1352
  - app/services/spree/sample_data/loader.rb
1353
+ - app/services/spree/search_provider/metafield_schema.rb
1349
1354
  - app/services/spree/seeds/admin_user.rb
1350
1355
  - app/services/spree/seeds/all.rb
1351
1356
  - app/services/spree/seeds/allowed_origins.rb
@@ -1605,6 +1610,8 @@ files:
1605
1610
  - db/migrate/20260711000001_add_preferences_to_spree_exports.rb
1606
1611
  - db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb
1607
1612
  - db/migrate/20260721000001_add_unique_type_index_to_spree_order_routing_rules.rb
1613
+ - db/migrate/20260722000001_add_searchable_sortable_to_spree_metafield_definitions.rb
1614
+ - db/migrate/20260727000001_add_unique_country_abbr_index_to_spree_states.rb
1608
1615
  - db/sample_data/channels.rb
1609
1616
  - db/sample_data/customers.csv
1610
1617
  - db/sample_data/markets.rb
@@ -1868,9 +1875,9 @@ licenses:
1868
1875
  - BSD-3-Clause
1869
1876
  metadata:
1870
1877
  bug_tracker_uri: https://github.com/spree/spree/issues
1871
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0
1878
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.1
1872
1879
  documentation_uri: https://docs.spreecommerce.org/
1873
- source_code_uri: https://github.com/spree/spree/tree/v5.6.0
1880
+ source_code_uri: https://github.com/spree/spree/tree/v5.6.1
1874
1881
  post_install_message:
1875
1882
  rdoc_options: []
1876
1883
  require_paths: