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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94ccab22a509de44665fb66add2dccdd315ac57a6d377320c9ab758fc1a7db1e
4
- data.tar.gz: 17dd4678e3435b928d8b03e2045de1f1e53f3b5cd6589cf89008272e1286f09e
3
+ metadata.gz: 102686daa285991fed8a91b768781fb7a3433411ffbc854cd2ef758c8c8687c7
4
+ data.tar.gz: 3c17b1e08025e190848c4699a5e6bbef00a66314b91054092d44c22971e3dbd2
5
5
  SHA512:
6
- metadata.gz: 850e850532de4436f179e4703ea1903e2e59512191c23369a7505650aad1988de58977127ee8aa78be9f8d831b1e61e835bb0ad7a3bce1bdc5460a11124d7458
7
- data.tar.gz: b7adf2ce0ccb80a24c11c98a007ce3f334fbfcebea342e1bb811d407e19b90753512d45c9ebb2959c0d18aee492a1737bd0db5a3878c9a29d00c32a646568a39
6
+ metadata.gz: 24354241aa89fcd8129c48e6ffacb1325334a17d6bc65d7481e3a8c0a7816365f7a73d9fd853831bcbe20a4373697be39c8e49f666d41d52409bd78d170575c1
7
+ data.tar.gz: 7a11fb6dd5229cab6f45019d3ba94b2820c1a00a477ce13efdf9bf5e0af819cc40df37179caded829a169706de110bcf5cebdc8371dee704fe1d9cbaaf897750
@@ -25,6 +25,12 @@ module Spree
25
25
  import = Spree::Import.find(import_id)
26
26
  Spree::Current.store = import.store
27
27
 
28
+ process_group(import, row_ids)
29
+ end
30
+
31
+ private
32
+
33
+ def process_group(import, row_ids)
28
34
  with_store_content_locale(import.store) do
29
35
  mappings = import.mappings.mapped.to_a
30
36
  schema_fields = import.schema_fields
@@ -33,40 +39,53 @@ module Spree
33
39
  started_at = Time.current
34
40
  processed_rows = []
35
41
 
36
- row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
37
- # Skip rows already completed on a prior attempt so retries don't double-process them.
38
- rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
39
- # Share the already-loaded import across rows: each row's processor reads
40
- # `row.import` (store, ability, lookup cache), and without this every row
41
- # lazily loads its own Import instance and rebuilds all of that per row.
42
- rows.each { |row| row.association(:import).target = import }
43
-
44
- if large
45
- Spree::Events.disable do
46
- rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
47
- end
48
- elsif grouped
49
- # A group is one product plus its variants: per-record lifecycle
50
- # events (variant.created, price.created, product.updated per
51
- # touch) are noise to subscribers — one product event is published
52
- # for the whole group below. import_row.* events still flow.
53
- Spree::Events.disable_lifecycle do
54
- rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
55
- end
56
- else
57
- rows.each do |row|
58
- row.process!(mappings: mappings, schema_fields: schema_fields)
42
+ skipping_row_events(import) do
43
+ row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
44
+ # Skip rows already completed on a prior attempt so retries don't double-process them.
45
+ rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
46
+ # Share the already-loaded import across rows: each row's processor reads
47
+ # `row.import` (store, ability, lookup cache), and without this every row
48
+ # lazily loads its own Import instance and rebuilds all of that per row.
49
+ rows.each { |row| row.association(:import).target = import }
50
+
51
+ if large
52
+ Spree::Events.disable do
53
+ rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
54
+ end
55
+ elsif grouped
56
+ # A group is one product plus its variants: per-record lifecycle
57
+ # events (variant.created, price.created, product.updated per
58
+ # touch) are noise to subscribers one product event is published
59
+ # for the whole group below. import_row.* events still flow.
60
+ Spree::Events.disable_lifecycle do
61
+ rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
62
+ end
63
+ else
64
+ rows.each do |row|
65
+ row.process!(mappings: mappings, schema_fields: schema_fields)
66
+ end
59
67
  end
68
+ processed_rows.concat(rows)
60
69
  end
61
- processed_rows.concat(rows)
70
+
71
+ publish_group_events(processed_rows, started_at) if grouped
62
72
  end
63
73
 
64
- publish_group_events(processed_rows, started_at) if grouped
74
+ # Outside `skipping_row_events` on purpose: the import's own
75
+ # lifecycle (`import.progress`, `import.completed`) is a handful of
76
+ # events per run and stays observable even for seeded data.
65
77
  check_import_completion(import, large)
66
78
  end
67
79
  end
68
80
 
69
- private
81
+ # Seeded catalogs (sample/demo data) shouldn't reach webhooks or
82
+ # analytics. Scoped to the rows: the import's own lifecycle events stay
83
+ # observable.
84
+ def skipping_row_events(import, &block)
85
+ return Spree::Events.disable(&block) if import.preferred_skip_events
86
+
87
+ block.call
88
+ end
70
89
 
71
90
  # One event per product touched by this group: `product.created` when the
72
91
  # product came into existence during this run, `product.updated` otherwise
@@ -23,6 +23,14 @@ module Spree
23
23
  end
24
24
  end
25
25
 
26
+ # Inline callers need the group finished before `perform` returns; the
27
+ # group job's completion bookkeeping works either way.
28
+ def dispatch_group(import, row_ids)
29
+ return ProcessGroupJob.perform_now(import.id, row_ids) if import.preferred_inline
30
+
31
+ ProcessGroupJob.perform_later(import.id, row_ids)
32
+ end
33
+
26
34
  def dispatch_grouped(import, file_column)
27
35
  groups = Hash.new { |h, k| h[k] = [] }
28
36
 
@@ -48,7 +56,7 @@ module Spree
48
56
  updated_at: Time.current
49
57
  )
50
58
 
51
- batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
59
+ batches.each { |row_ids| dispatch_group(import, row_ids) }
52
60
  end
53
61
 
54
62
  def dispatch_batched(import)
@@ -64,7 +72,7 @@ module Spree
64
72
  updated_at: Time.current
65
73
  )
66
74
 
67
- row_id_batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
75
+ row_id_batches.each { |row_ids| dispatch_group(import, row_ids) }
68
76
  end
69
77
  end
70
78
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Resolves `cf_*` custom-field predicates (e.g. +cf_custom_material_i_cont+)
5
+ # into scopes. Ransack has no such attributes, so callers split them out of a
6
+ # filter hash with {with_metafield_filters} before handing the rest to
7
+ # Ransack. Lives on the model rather than in a SearchProvider so every
8
+ # consumer — the search providers, CSV export, any other Ransack entry
9
+ # point — resolves the same vocabulary.
10
+ module MetafieldFilterable
11
+ extend ActiveSupport::Concern
12
+
13
+ # Arel comparison methods for numeric predicates.
14
+ NUMERIC_OPERATORS = {
15
+ 'eq' => :eq,
16
+ 'gt' => :gt,
17
+ 'gteq' => :gteq,
18
+ 'lt' => :lt,
19
+ 'lteq' => :lteq
20
+ }.freeze
21
+
22
+ # Wraps a value in the LIKE pattern its predicate implies.
23
+ LIKE_PATTERNS = {
24
+ 'cont' => ->(value) { "%#{value}%" },
25
+ 'i_cont' => ->(value) { "%#{value}%" },
26
+ 'start' => ->(value) { "#{value}%" },
27
+ 'end' => ->(value) { "%#{value}" }
28
+ }.freeze
29
+
30
+ class_methods do
31
+ # Applies every `cf_*` predicate in +filters+ and returns the resulting
32
+ # scope alongside the filters Ransack should still handle. Predicates
33
+ # that don't apply (unknown key, wrong type, unparseable value) are
34
+ # dropped rather than raising, so a stale bookmarked filter degrades to
35
+ # "no filter" instead of a 500.
36
+ #
37
+ # @param filters [Hash]
38
+ # @param schema [Spree::SearchProvider::MetafieldSchema, nil]
39
+ # @return [Array(ActiveRecord::Relation, Hash)] scoped relation + remaining filters
40
+ def with_metafield_filters(filters, schema: nil)
41
+ return [all, filters] if filters.blank?
42
+ # Skip the schema's DB query entirely for the common case of a filter
43
+ # hash with no custom-field predicates at all.
44
+ return [all, filters] unless filters.any? { |key, _| key.to_s.start_with?('cf_') }
45
+
46
+ schema ||= Spree::SearchProvider::MetafieldSchema.new
47
+ scope = all
48
+
49
+ remaining = filters.reject do |key, value|
50
+ parsed = schema.parse_filter(key)
51
+ next false unless parsed
52
+
53
+ condition = metafield_filter_condition(parsed[:definition], parsed[:predicate], value)
54
+ scope = scope.where(condition) if condition
55
+ true
56
+ end
57
+
58
+ [scope, remaining]
59
+ end
60
+
61
+ # One EXISTS / NOT EXISTS subquery per predicate — composes with other
62
+ # filters without duplicating rows the way a join would.
63
+ #
64
+ # @param definition [Spree::MetafieldDefinition]
65
+ # @param predicate [String]
66
+ # @param value [Object]
67
+ # @return [Arel::Nodes::Node, nil] nil when the value is unusable
68
+ def metafield_filter_condition(definition, predicate, value)
69
+ case predicate
70
+ when 'present'
71
+ metafield_exists(definition)
72
+ when 'blank'
73
+ metafield_exists(definition).not
74
+ else
75
+ value_condition = metafield_value_condition(definition.field_type, predicate, value)
76
+ value_condition && metafield_exists(definition, value_condition)
77
+ end
78
+ end
79
+
80
+ # Type-casts a metafield value column so numeric fields compare
81
+ # numerically rather than lexicographically. Public because metafield
82
+ # sorting in Spree::SearchProvider::Database applies the same cast to a
83
+ # differently-aliased join.
84
+ #
85
+ # @param column [Arel::Attributes::Attribute] value column
86
+ # @param field_type [String]
87
+ # @return [Arel::Nodes::Node] castable expression
88
+ def metafield_value_expression(column, field_type)
89
+ return column unless field_type == 'number'
90
+
91
+ Arel::Nodes::NamedFunction.new('CAST', [column.as(metafield_numeric_cast_type)])
92
+ end
93
+
94
+ private
95
+
96
+ # `CAST(... AS <type>)` target for numeric metafield comparisons. SQLite
97
+ # has no DECIMAL affinity and PostgreSQL rejects MySQL's precision form,
98
+ # so the type is per-adapter.
99
+ def metafield_numeric_cast_type
100
+ case connection.adapter_name
101
+ when /PostgreSQL/i then 'numeric'
102
+ when /Mysql|Trilogy/i then 'DECIMAL(30, 10)'
103
+ else 'REAL'
104
+ end
105
+ end
106
+
107
+ # `EXISTS (SELECT 1 FROM spree_metafields WHERE …)` correlated against
108
+ # this model's rows, optionally narrowed by a value condition.
109
+ def metafield_exists(definition, value_condition = nil)
110
+ metafields = metafield_filter_table
111
+ subquery = Arel::SelectManager.new(metafields).
112
+ project(Arel.sql('1')).
113
+ where(metafields[:resource_type].eq(name)).
114
+ where(metafields[:resource_id].eq(arel_table[primary_key])).
115
+ where(metafields[:metafield_definition_id].eq(definition.id))
116
+
117
+ subquery = subquery.where(value_condition) if value_condition
118
+
119
+ # `.ast`, not the SelectManager — Exists adds its own parentheses, and
120
+ # a manager contributes a second set, yielding invalid `EXISTS ((…))`.
121
+ Arel::Nodes::Exists.new(subquery.ast)
122
+ end
123
+
124
+ # Aliased so the correlated subquery can't collide with an outer join on
125
+ # the same table (metafield sorting joins it as `sort_cf`).
126
+ def metafield_filter_table
127
+ @metafield_filter_table ||= Spree::Metafield.arel_table.alias('filter_cf')
128
+ end
129
+
130
+ def metafield_value_condition(field_type, predicate, value)
131
+ column = metafield_filter_table[:value]
132
+
133
+ if field_type == 'number'
134
+ operator = NUMERIC_OPERATORS[predicate]
135
+ return nil unless operator
136
+
137
+ numeric = begin
138
+ BigDecimal(value.to_s)
139
+ rescue ArgumentError, TypeError
140
+ return nil
141
+ end
142
+
143
+ metafield_value_expression(column, field_type).public_send(operator, numeric)
144
+ elsif (pattern = LIKE_PATTERNS[predicate])
145
+ escaped = sanitize_sql_like(value.to_s)
146
+ # LIKE is already case-insensitive under MySQL's default collation
147
+ # and for ASCII on SQLite, but not on PostgreSQL — lower both sides
148
+ # so `i_cont` behaves the same everywhere.
149
+ if predicate == 'i_cont'
150
+ column.lower.matches(pattern.call(escaped.downcase), nil, true)
151
+ else
152
+ column.matches(pattern.call(escaped), nil, true)
153
+ end
154
+ elsif predicate == 'eq'
155
+ column.eq(value.to_s)
156
+ elsif predicate == 'not_eq'
157
+ column.not_eq(value.to_s)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -52,8 +52,13 @@ module Spree
52
52
  (setup_tasks_done / setup_tasks_total.to_f * 100).to_i
53
53
  end
54
54
 
55
+ # Payments count as set up only once customers can actually pay at
56
+ # storefront checkout: admin-only (back_end) methods and store credit
57
+ # don't complete the task.
58
+ #
59
+ # @return [Boolean]
55
60
  def payment_method_setup?
56
- payment_methods.active.where.not(type: Spree::PaymentMethod::StoreCredit.to_s).any?
61
+ payment_methods.active.storefront_visible.where.not(type: Spree::PaymentMethod::StoreCredit.to_s).any?
57
62
  end
58
63
 
59
64
  # A storefront counts as set up once the merchant has saved the storefront
@@ -92,6 +92,41 @@ class Spree::Base < ApplicationRecord
92
92
  api_type
93
93
  end
94
94
 
95
+ # `.api_type` for an STI `type` column value, without instantiating the
96
+ # subclass — use this in serializers instead of `record.class.api_type`,
97
+ # which reports the *loaded* class and so returns the base type for a record
98
+ # read through the parent (a plain `Spree::Export` row, a factory that sets
99
+ # `type` as an attribute, a query on the base relation).
100
+ #
101
+ # Resolves against `available_types` where the class defines it, so an
102
+ # unrecognized column value passes through untouched rather than being
103
+ # constantized.
104
+ #
105
+ # @param type [String, nil] value of the STI `type` column
106
+ # @return [String]
107
+ def self.api_type_for(type)
108
+ return api_type if type.blank?
109
+
110
+ type = type.to_s
111
+ return type unless respond_to?(:available_types)
112
+
113
+ available_types.find { |klass| klass.to_s == type }&.api_type || type
114
+ end
115
+
116
+ # Shorthand for a *polymorphic* `*_type` column (`owner_type`,
117
+ # `viewable_type`, …), where the value names an arbitrary model rather than a
118
+ # subclass of the serialized one — so `api_type_for`'s registry lookup does
119
+ # not apply. Demodulizes and underscores the same way `api_type` does, giving
120
+ # `"Spree::Product"` => `"product"`.
121
+ #
122
+ # @param type [String, Class, nil] value of the polymorphic type column
123
+ # @return [String, nil]
124
+ def self.polymorphic_api_type(type)
125
+ return nil if type.blank?
126
+
127
+ type.to_s.demodulize.underscore
128
+ end
129
+
95
130
  def self.to_tom_select_json
96
131
  pluck(:name, :id).map do |name, id|
97
132
  {
@@ -14,10 +14,10 @@ module Spree
14
14
  STOREFRONT_ACCESS = %w[public prices_hidden login_required].freeze
15
15
 
16
16
  included do
17
- # Empty -> falls back to the Store-level preference. `guest_checkout` is
18
- # `nullable` so a blank write stays nil (inherit) rather than coercing to
19
- # false, while an explicit true/false remains a channel override.
20
- preference :storefront_access, :string, default: nil
17
+ # Empty -> falls back to the Store-level preference. Both are `nullable`
18
+ # so a blank write stays nil (inherit) rather than coercing to "" or
19
+ # false, while an explicit value remains a channel override.
20
+ preference :storefront_access, :string, default: nil, nullable: true
21
21
  preference :guest_checkout, :boolean, default: nil, nullable: true
22
22
 
23
23
  validate :storefront_access_must_be_valid
@@ -138,12 +138,20 @@ module Spree
138
138
  end
139
139
 
140
140
  def records_to_export
141
- if search_params.present?
142
- params = search_params.is_a?(String) ? JSON.parse(search_params.to_s).to_h : search_params
143
- scope.ransack(decode_prefixed_id_filters(params))
144
- else
145
- scope.ransack
146
- end.result
141
+ return scope.ransack.result if search_params.blank?
142
+
143
+ params = search_params.is_a?(String) ? JSON.parse(search_params.to_s).to_h : search_params
144
+ params = decode_prefixed_id_filters(params)
145
+
146
+ # `cf_*` custom-field predicates aren't Ransack attributes — resolve them
147
+ # first so an export of a filtered list matches what the admin sees.
148
+ filtered_scope, params = if scope.respond_to?(:with_metafield_filters)
149
+ scope.with_metafield_filters(params)
150
+ else
151
+ [scope, params]
152
+ end
153
+
154
+ filtered_scope.ransack(params).result
147
155
  end
148
156
 
149
157
  # Replace any prefixed IDs in `search_params` with their raw DB IDs so
@@ -7,6 +7,15 @@ module Spree
7
7
  include Spree::NumberIdentifier
8
8
  include Spree::Core::NumberGenerator.new(prefix: 'IM')
9
9
 
10
+ # Where the downloadable example CSVs are served from — see `sample_csv_url`.
11
+ #
12
+ # Pinned to the installed version's tag rather than `main`: whether a type
13
+ # *has* an example is answered by the local `db/sample_data`, so pointing
14
+ # elsewhere would let a released install link to a newer, incompatible
15
+ # schema (or a file that has since been removed).
16
+ SAMPLE_DATA_BASE_URL_TEMPLATE =
17
+ 'https://raw.githubusercontent.com/spree/spree/refs/tags/v%<version>s/spree/core/db/sample_data'.freeze
18
+
10
19
  publishes_lifecycle_events
11
20
 
12
21
  # Set event prefix for all Import subclasses
@@ -88,11 +97,58 @@ module Spree
88
97
  # Preferences
89
98
  #
90
99
  preference :delimiter, :string, default: ','
100
+ # Run the pipeline in-process instead of enqueuing it. For rake tasks, seeds
101
+ # and the console, where the caller needs the data to exist when the call
102
+ # returns and there may be no worker attached. Never set it from a request:
103
+ # a large CSV blocks for minutes.
104
+ #
105
+ # A preference, not a virtual attribute, so it survives the `Import.find`
106
+ # each processing job does — otherwise every job would have to take (and
107
+ # forward) an `inline:` argument.
108
+ preference :inline, :boolean, default: false
109
+ # Suppress the events the *rows* publish — a `product.created` per imported
110
+ # product, plus `import_row.*`. The import's own `import.*` events still
111
+ # fire: a handful per import is useful, thousands from a seeded catalog are
112
+ # not.
113
+ #
114
+ # For seeding demo or sample data, where subscribers would otherwise deliver
115
+ # webhooks and analytics for a catalog nobody actually created. A preference
116
+ # rather than a virtual attribute because the processing jobs each reload
117
+ # the record, so it has to outlive the enqueue. Independent of `inline`: a
118
+ # background import can still be silent, and an inline one observed.
119
+ preference :skip_events, :boolean, default: false
91
120
  # Absolute URL of the dashboard's imports view, captured at create and
92
121
  # validated against the store's allowed origins — the import-done email
93
122
  # links back to it. Blank for legacy-admin or app-created imports.
94
123
  preference :results_url, :string, default: nil
95
124
 
125
+ # Publicly downloadable example CSV for this import's type, or nil when the
126
+ # type has no sample file. Resolved from the `type` column rather than the
127
+ # instance's class, so it works on a record built as the base
128
+ # `Spree::Import` with `type` assigned (which is how the admin's new-import
129
+ # form builds it).
130
+ #
131
+ # @return [String, nil]
132
+ def sample_csv_url
133
+ self.class.available_types.find { |klass| klass.to_s == type.to_s }&.sample_csv_url
134
+ end
135
+
136
+ # Header-only CSV for this import's schema — the blank counterpart to
137
+ # `sample_csv_url`. One line, so callers can inline it (e.g. as a `data:`
138
+ # URI) instead of round-tripping to the server for it.
139
+ #
140
+ # @return [String]
141
+ def template_csv
142
+ ::CSV.generate_line(schema_fields.map { |field| field[:name] })
143
+ end
144
+
145
+ # Suggested filename for `template_csv`, e.g. "products_import_template.csv".
146
+ #
147
+ # @return [String]
148
+ def template_csv_filename
149
+ "#{self.class.api_type_for(type)}_import_template.csv"
150
+ end
151
+
96
152
  # Returns true if the import has more rows than the large import threshold.
97
153
  # Large imports skip per-row UI broadcasts and use bulk processing.
98
154
  # @return [Boolean]
@@ -289,12 +345,18 @@ module Spree
289
345
  # Creates rows asynchronously
290
346
  # @return [void]
291
347
  def create_rows_async
348
+ # The 2s delay lets the attachment settle before the job reads it; running
349
+ # inline there is nothing to wait for.
350
+ return Spree::Imports::CreateRowsJob.perform_now(id) if preferred_inline
351
+
292
352
  Spree::Imports::CreateRowsJob.set(wait: 2.seconds).perform_later(id)
293
353
  end
294
354
 
295
355
  # Processes rows asynchronously
296
356
  # @return [void]
297
357
  def process_rows_async
358
+ return Spree::Imports::ProcessRowsJob.perform_now(id) if preferred_inline
359
+
298
360
  Spree::Imports::ProcessRowsJob.perform_later(id)
299
361
  end
300
362
 
@@ -360,6 +422,36 @@ module Spree
360
422
  to_s.demodulize.underscore.to_sym
361
423
  end
362
424
 
425
+ # Publicly downloadable example CSV for this import type — the same file
426
+ # `rake spree:load_sample_data` feeds through this very pipeline, so it is
427
+ # a working example rather than a hand-maintained sample that can drift
428
+ # from the schema.
429
+ #
430
+ # Whether a type *has* an example is answered by `db/sample_data` itself,
431
+ # so adding or removing a CSV there needs no change here; a type with no
432
+ # sample file returns nil and the UI omits the link. The URL is pinned to
433
+ # the installed version's tag so the file served always matches the schema
434
+ # that check was made against.
435
+ #
436
+ # @return [String, nil]
437
+ def sample_csv_url
438
+ return nil if self == Spree::Import
439
+ return nil unless Spree::Core::Engine.root.join('db', 'sample_data', sample_csv_filename).exist?
440
+
441
+ [sample_data_base_url, sample_csv_filename].join('/')
442
+ end
443
+
444
+ # @return [String]
445
+ def sample_data_base_url
446
+ format(SAMPLE_DATA_BASE_URL_TEMPLATE, version: Spree.version)
447
+ end
448
+
449
+ # eg. Spree::Imports::ProductTranslations => "product_translations.csv"
450
+ # @return [String]
451
+ def sample_csv_filename
452
+ "#{api_type}.csv"
453
+ end
454
+
363
455
  # eg. Spree::Imports::Orders => Spree::Order
364
456
  def model_class
365
457
  return Spree.user_class if to_s == 'Spree::Imports::Customers'
@@ -21,6 +21,17 @@ module Spree
21
21
  # in OpenAPI (string-array form was added in typelizer 0.10.0).
22
22
  FIELD_TYPE_TOKENS = TYPE_TOKENS.keys.freeze
23
23
 
24
+ # Whether MetafieldDefinition may enable `searchable` / `sortable` for this
25
+ # STI type. Used only by definition validations (not by SearchProvider
26
+ # indexing). Defaults to false; subclasses override (e.g. ShortText).
27
+ def self.searchable?
28
+ false
29
+ end
30
+
31
+ def self.sortable?
32
+ false
33
+ end
34
+
24
35
  has_prefix_id :cf
25
36
 
26
37
  #
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class MetafieldDefinition
5
+ module SearchCapabilities
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attribute :searchable, :boolean, default: false
10
+ attribute :sortable, :boolean, default: false
11
+
12
+ validate :search_sort_capabilities_compatible_with_type
13
+ validate :filter_key_must_be_unique
14
+
15
+ scope :searchable, -> { where(searchable: true) }
16
+ scope :sortable, -> { where(sortable: true) }
17
+ end
18
+
19
+ class_methods do
20
+ # @return [Array<String>] API field_type tokens whose STI class is searchable
21
+ def searchable_field_type_tokens
22
+ @searchable_field_type_tokens ||= build_field_type_tokens(&:searchable?)
23
+ end
24
+
25
+ # @return [Array<String>] API field_type tokens whose STI class is sortable
26
+ def sortable_field_type_tokens
27
+ @sortable_field_type_tokens ||= build_field_type_tokens(&:sortable?)
28
+ end
29
+
30
+ private
31
+
32
+ # Builds API tokens from {Spree::MetafieldDefinition.available_types}
33
+ # (`Spree.metafields.types` — in-memory registry, not a DB query).
34
+ def build_field_type_tokens(&block)
35
+ available_types.filter_map do |klass|
36
+ next unless block.call(klass)
37
+
38
+ Spree::Metafield::TYPE_CLASS_TO_TOKEN[klass.to_s] || klass.to_s
39
+ end
40
+ end
41
+ end
42
+
43
+ # Public identifier for sorting and filtering product listings by this
44
+ # custom field (+sort=cf_specs_material+,
45
+ # +q[cf_specs_material_i_cont]=wool+).
46
+ #
47
+ # @return [String] e.g. +cf_custom_label+
48
+ def filter_key
49
+ "cf_#{namespace}_#{key}"
50
+ end
51
+
52
+ private
53
+
54
+ # `key` is already unique per (resource_type, namespace), but both
55
+ # segments normalize to underscored slugs, so two different splits
56
+ # ("a_b"/"c" vs "a"/"b_c") flatten to one filter_key — which would make
57
+ # one of them unaddressable as a sort/filter param.
58
+ def filter_key_must_be_unique
59
+ return if namespace.blank? || key.blank? || resource_type.blank?
60
+ return unless new_record? || namespace_changed? || key_changed? || resource_type_changed?
61
+
62
+ table = self.class.arel_table
63
+ concatenated = Arel::Nodes::NamedFunction.new(
64
+ 'CONCAT', [table[:namespace], Arel::Nodes.build_quoted('_'), table[:key]]
65
+ )
66
+
67
+ scope = self.class.
68
+ for_resource_type(resource_type).
69
+ where(concatenated.eq("#{namespace}_#{key}")).
70
+ where(self.class.spree_base_uniqueness_scope.index_with { |attr| public_send(attr) })
71
+ scope = scope.where.not(id: id) if persisted?
72
+
73
+ errors.add(:key, :taken) if scope.exists?
74
+ end
75
+
76
+ def metafield_type_class
77
+ metafield_type.presence&.safe_constantize
78
+ end
79
+
80
+ def search_sort_capabilities_compatible_with_type
81
+ return unless searchable? || sortable?
82
+
83
+ klass = metafield_type_class
84
+ return if klass.nil?
85
+
86
+ %i[searchable sortable].each do |capability|
87
+ next unless public_send("#{capability}?")
88
+ next if klass.public_send("#{capability}?")
89
+
90
+ errors.add(capability, capability_error_message(capability))
91
+ end
92
+ end
93
+
94
+ def capability_error_message(capability)
95
+ labels = self.class
96
+ .public_send("#{capability}_field_type_tokens")
97
+ .map { |token| token.tr('_', ' ') }
98
+ .join(', ')
99
+
100
+ "is only supported for field types: #{labels}"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :cfdef
4
4
 
5
5
  include Spree::DisplayOn
6
+ include Spree::MetafieldDefinition::SearchCapabilities
6
7
 
7
8
  DISPLAY = [:both, :back_end]
8
9
 
@@ -49,7 +50,7 @@ module Spree
49
50
  #
50
51
  # Ransack
51
52
  #
52
- self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
53
+ self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on searchable sortable]
53
54
  self.whitelisted_ransackable_scopes = %w[search multi_search]
54
55
 
55
56
  # API naming bridge — internal columns rename in 6.0. `label` matches