shopify_product_taxonomy 1.1.1 → 1.2.0

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 (26) hide show
  1. checksums.yaml +4 -4
  2. data/lib/product_taxonomy/alphanumeric_sorter.rb +3 -3
  3. data/lib/product_taxonomy/command_executor.rb +29 -0
  4. data/lib/product_taxonomy/dist_asset_stager.rb +114 -0
  5. data/lib/product_taxonomy/loader.rb +3 -0
  6. data/lib/product_taxonomy/localizations_validator.rb +5 -2
  7. data/lib/product_taxonomy/models/category.rb +52 -6
  8. data/lib/product_taxonomy/models/disclosure.rb +289 -0
  9. data/lib/product_taxonomy/models/mixins/formatted_validation_errors.rb +7 -1
  10. data/lib/product_taxonomy/models/return_reason.rb +20 -0
  11. data/lib/product_taxonomy/models/serializers/category/data/data_serializer.rb +16 -9
  12. data/lib/product_taxonomy/models/serializers/category/dist/json_serializer.rb +1 -0
  13. data/lib/product_taxonomy/models/serializers/category/docs/siblings_serializer.rb +1 -0
  14. data/lib/product_taxonomy/models/serializers/disclosure/data/data_serializer.rb +48 -0
  15. data/lib/product_taxonomy/models/serializers/disclosure/data/localizations_serializer.rb +37 -0
  16. data/lib/product_taxonomy/models/serializers/disclosure/dist/json_serializer.rb +52 -0
  17. data/lib/product_taxonomy/models/serializers/disclosure/dist/txt_serializer.rb +46 -0
  18. data/lib/product_taxonomy/models/serializers/disclosure/docs/search_serializer.rb +32 -0
  19. data/lib/product_taxonomy/models/serializers/disclosure/docs/yaml_serializer.rb +37 -0
  20. data/lib/product_taxonomy/models/serializers/value/docs/reversed_serializer.rb +37 -0
  21. data/lib/product_taxonomy/models/serializers/value/docs/search_serializer.rb +32 -0
  22. data/lib/product_taxonomy/release_asset_publisher.rb +93 -0
  23. data/lib/product_taxonomy/tagged_release_workspace.rb +59 -0
  24. data/lib/product_taxonomy/version.rb +1 -1
  25. data/lib/product_taxonomy.rb +13 -0
  26. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 436ce3b89bb5636833505ce02d1bf30e96346158b2591630eccb840c0184dd32
4
- data.tar.gz: 39d468ca12f1d50151eb5918aac1a1fda8122b9504d22db18e7331bc89873dd5
3
+ metadata.gz: da6547214a08a1014e88188ab157220a6f5cd23d60d0bb31074ff3e5418c5a7d
4
+ data.tar.gz: 3a06da2e5a5500d7a000b46e68307921291072120a52fd828b644b9188ca6760
5
5
  SHA512:
6
- metadata.gz: 83aa402e17c4aaa1ce91980ea2bfac9652fa36ea7b9667ae910ca90030c187e2db3383cfbb70cf8ad6fa23d81ae14d4887639bbf4b8e67f4bec94ccf1ca94ef0
7
- data.tar.gz: 8edb5a665aaea1c8ea7acf24b379fa7e3e346b5977313a4f03ab5fa0d74842e11bc3f167865b452ec80dea1692c64cc55fccf7f8266828f56c87cdd7da615891
6
+ metadata.gz: 97d4b42d26d93a1566f3e216f6be82357fcae6f30b771640a137182035d74f0bf545c3f1c3db69572f6b639d5d1d501b11217475bc9a15dd813da3b837c35c41
7
+ data.tar.gz: 188d03a6b94f9082fc80c3eee0d4f4400ad6dffed29a53542324ab6c7503d56c5aaf2a5b6213d34731c38b9ff96966a5ef3a0ff7bd4a3d7dc7c0beadbbb75ccf
@@ -37,13 +37,13 @@ module ProductTaxonomy
37
37
 
38
38
  def normalize_sequential(match)
39
39
  [
40
- normalize_text(match[:primary_text]),
40
+ normalize_text(match[:primary_text]) || "",
41
41
  normalize_single_number(match[:primary_step]),
42
42
  normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
43
43
  normalize_text(match[:seperator]) || "-",
44
- normalize_text(match[:secondary_text]),
44
+ normalize_text(match[:secondary_text]) || "",
45
45
  normalize_single_number(match[:secondary_step]),
46
- normalize_text(match[:trailing_text]),
46
+ normalize_text(match[:trailing_text]) || "",
47
47
  ]
48
48
  end
49
49
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module ProductTaxonomy
6
+ class CommandExecutor
7
+ DEFAULT_RUNNER = Open3.method(:capture3)
8
+
9
+ def initialize(command_runner: DEFAULT_RUNNER)
10
+ @command_runner = command_runner
11
+ end
12
+
13
+ def run!(*command, chdir:, failure_message:)
14
+ stdout, stderr, status = capture(*command, chdir:, failure_message:)
15
+ return stdout if status.success?
16
+
17
+ details = stderr.strip
18
+ details = stdout.strip if details.empty?
19
+ message = details.empty? ? failure_message : "#{failure_message} #{details}"
20
+ raise message
21
+ end
22
+
23
+ def capture(*command, chdir:, failure_message:)
24
+ @command_runner.call(*command, chdir:)
25
+ rescue SystemCallError => error
26
+ raise "#{failure_message} #{error.message}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "zlib"
5
+
6
+ module ProductTaxonomy
7
+ class DistAssetStager
8
+ ALLOWED_ROOT_FILES = ["README.md"].freeze
9
+ DATA_EXTENSIONS = [".json", ".txt"].freeze
10
+ LOCALE_PATTERN = /\A[a-z]{2,3}(?:-[A-Z]{2})?\z/
11
+
12
+ def initialize(input_path:, output_path:)
13
+ @input_path = File.expand_path(input_path)
14
+ @output_path = File.expand_path(output_path)
15
+ end
16
+
17
+ def stage
18
+ validate_paths!
19
+ staging_plan = build_staging_plan
20
+ validate_collisions!(staging_plan)
21
+
22
+ FileUtils.rm_rf(@output_path)
23
+ FileUtils.mkdir_p(@output_path)
24
+
25
+ staging_plan.map do |source_path, asset_name|
26
+ destination_path = File.join(@output_path, asset_name)
27
+ gzip_file(source_path, destination_path)
28
+ destination_path
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def validate_paths!
35
+ raise ArgumentError, "Input path does not exist: #{@input_path}" unless File.directory?(@input_path)
36
+
37
+ paths_overlap = @output_path == @input_path ||
38
+ @output_path.start_with?("#{@input_path}#{File::SEPARATOR}") ||
39
+ @input_path.start_with?("#{@output_path}#{File::SEPARATOR}")
40
+ raise ArgumentError, "Input and output paths must not overlap" if paths_overlap
41
+
42
+ root_entries = Dir.children(@input_path).sort
43
+ unexpected_files = root_entries.reject do |entry|
44
+ path = File.join(@input_path, entry)
45
+ File.directory?(path) || (ALLOWED_ROOT_FILES.include?(entry) && File.file?(path))
46
+ end
47
+ unless unexpected_files.empty?
48
+ raise ArgumentError, "Unexpected files at distribution root: #{unexpected_files.join(", ")}"
49
+ end
50
+
51
+ unexpected_directories = root_entries.select do |entry|
52
+ File.directory?(File.join(@input_path, entry)) && !entry.match?(LOCALE_PATTERN)
53
+ end
54
+ return if unexpected_directories.empty?
55
+
56
+ raise ArgumentError, "Unexpected directories at distribution root: #{unexpected_directories.join(", ")}"
57
+ end
58
+
59
+ def build_staging_plan
60
+ locale_directories.flat_map do |locale_directory|
61
+ locale = File.basename(locale_directory)
62
+ data_files(locale_directory).map do |source_path|
63
+ [source_path, asset_name(source_path, locale_directory, locale)]
64
+ end
65
+ end.sort_by(&:last)
66
+ end
67
+
68
+ def locale_directories
69
+ Dir.children(@input_path).sort.filter_map do |entry|
70
+ path = File.join(@input_path, entry)
71
+ path if File.directory?(path)
72
+ end
73
+ end
74
+
75
+ def data_files(locale_directory)
76
+ files = Dir.glob(File.join(locale_directory, "**", "*"), File::FNM_DOTMATCH).select { File.file?(_1) }.sort
77
+ unexpected_files = files.reject { DATA_EXTENSIONS.include?(File.extname(_1)) }
78
+ unless unexpected_files.empty?
79
+ relative_paths = unexpected_files.map { _1.delete_prefix("#{@input_path}#{File::SEPARATOR}") }
80
+ raise ArgumentError, "Unexpected files in locale directories: #{relative_paths.join(", ")}"
81
+ end
82
+
83
+ files
84
+ end
85
+
86
+ def asset_name(source_path, locale_directory, locale)
87
+ relative_path = source_path.delete_prefix("#{locale_directory}#{File::SEPARATOR}")
88
+ extension = File.extname(relative_path)
89
+ basename = File.basename(relative_path, extension)
90
+ relative_directory = File.dirname(relative_path)
91
+ directory_parts = relative_directory == "." ? [] : relative_directory.split(File::SEPARATOR)
92
+
93
+ [*directory_parts, basename, locale].join(".") + extension + ".gz"
94
+ end
95
+
96
+ def gzip_file(source_path, destination_path)
97
+ Zlib::GzipWriter.open(destination_path) do |gzip_writer|
98
+ gzip_writer.mtime = 0
99
+ File.open(source_path, "rb") { IO.copy_stream(_1, gzip_writer) }
100
+ end
101
+ end
102
+
103
+ def validate_collisions!(staging_plan)
104
+ collisions = staging_plan.group_by(&:last).select { |_, sources| sources.length > 1 }
105
+ return if collisions.empty?
106
+
107
+ details = collisions.sort.map do |asset_name, sources|
108
+ source_paths = sources.map(&:first).join(", ")
109
+ "#{asset_name} (#{source_paths})"
110
+ end
111
+ raise ArgumentError, "Distribution asset naming collision: #{details.join("; ")}"
112
+ end
113
+ end
114
+ end
@@ -13,12 +13,14 @@ module ProductTaxonomy
13
13
  values_path = File.join(data_path, "values.yml")
14
14
  attributes_path = File.join(data_path, "attributes.yml")
15
15
  return_reasons_path = File.join(data_path, "return_reasons.yml")
16
+ disclosures_path = File.join(data_path, "disclosures.yml")
16
17
  categories_glob = Dir.glob(File.join(data_path, "categories", "*.yml"))
17
18
 
18
19
  begin
19
20
  ProductTaxonomy::Value.load_from_source(YAML.load_file(values_path))
20
21
  ProductTaxonomy::Attribute.load_from_source(YAML.load_file(attributes_path))
21
22
  ProductTaxonomy::ReturnReason.load_from_source(YAML.load_file(return_reasons_path))
23
+ ProductTaxonomy::Disclosure.load_from_source(YAML.load_file(disclosures_path))
22
24
 
23
25
  categories_source_data = categories_glob.each_with_object([]) do |file, array|
24
26
  array.concat(YAML.safe_load_file(file))
@@ -32,6 +34,7 @@ module ProductTaxonomy
32
34
 
33
35
  # Run validations that can only be run after the taxonomy has been loaded.
34
36
  ProductTaxonomy::Value.all.each { |model| model.validate!(:taxonomy_loaded) }
37
+ ProductTaxonomy::Disclosure.all.each { |model| model.validate!(:taxonomy_loaded) }
35
38
  end
36
39
  end
37
40
  end
@@ -11,6 +11,7 @@ module ProductTaxonomy
11
11
  Category.validate_localizations!(locales)
12
12
  Attribute.validate_localizations!(locales)
13
13
  Value.validate_localizations!(locales)
14
+ ReturnReason.validate_localizations!(locales)
14
15
 
15
16
  validate_locales_are_consistent! if locales.nil?
16
17
  end
@@ -21,10 +22,12 @@ module ProductTaxonomy
21
22
  categories_locales = Category.localizations.keys
22
23
  attributes_locales = Attribute.localizations.keys
23
24
  values_locales = Value.localizations.keys
25
+ return_reasons_locales = ReturnReason.localizations.keys
24
26
 
25
27
  error_message = "Not all model localizations have the same set of locales"
26
- raise ArgumentError,
27
- error_message unless categories_locales == attributes_locales && attributes_locales == values_locales
28
+ all_locales_match = [attributes_locales, values_locales, return_reasons_locales]
29
+ .all? { |locales| locales == categories_locales }
30
+ raise ArgumentError, error_message unless all_locales_match
28
31
  end
29
32
  end
30
33
  end
@@ -22,7 +22,7 @@ module ProductTaxonomy
22
22
  id: item["id"],
23
23
  name: item["name"],
24
24
  attributes: Array(item["attributes"]).map { Attribute.find_by(friendly_id: _1) || _1 },
25
- return_reasons: Array(item["return_reasons"]).map { ReturnReason.find_by(friendly_id: _1) || _1 },
25
+ return_reasons: parse_return_reasons(item["return_reasons"]),
26
26
  )
27
27
  end
28
28
 
@@ -42,6 +42,10 @@ module ProductTaxonomy
42
42
  root_nodes << node if node.root?
43
43
  end
44
44
  @verticals.sort_by!(&:name)
45
+
46
+ # Fourth pass: derive each category's effective return reasons — inherited from the closest defining ancestor,
47
+ # falling back to the global reasons when nothing is defined.
48
+ Category.all.each(&:resolve_return_reasons)
45
49
  end
46
50
 
47
51
  # Reset all class-level state
@@ -60,6 +64,13 @@ module ProductTaxonomy
60
64
 
61
65
  private
62
66
 
67
+ # `return_reasons: inherit` in the data marks a category as inheriting; anything else is an explicit list.
68
+ def parse_return_reasons(value)
69
+ return :inherit if value == "inherit"
70
+
71
+ Array(value).map { |friendly_id| ReturnReason.find_by(friendly_id:) || friendly_id }
72
+ end
73
+
63
74
  def add_children(type:, item:, parent:)
64
75
  item[type]&.each do |child_id|
65
76
  child = Category.find_by(id: child_id) || child_id
@@ -84,16 +95,24 @@ module ProductTaxonomy
84
95
  validate :id_starts_with_parent_id, unless: :root?, on: :category_tree_loaded
85
96
  validate :children_found?, on: :category_tree_loaded
86
97
  validate :secondary_children_found?, on: :category_tree_loaded
98
+ validate :root_does_not_inherit_return_reasons, if: :root?, on: :category_tree_loaded
87
99
 
88
100
  localized_attr_reader :name, keyed_by: :id
89
101
 
90
- attr_reader :id, :children, :secondary_children, :attributes, :return_reasons
102
+ attr_reader :id,
103
+ :children,
104
+ :secondary_children,
105
+ :attributes,
106
+ :return_reasons,
107
+ :defined_return_reasons,
108
+ :inherits_return_reasons
91
109
  attr_accessor :parent, :secondary_parents
92
110
 
93
111
  # @param id [String] The ID of the category.
94
112
  # @param name [String] The name of the category.
95
113
  # @param attributes [Array<Attribute>] The attributes of the category.
96
- # @param return_reasons [Array<ReturnReason>] The return reasons for the category.
114
+ # @param return_reasons [Array<ReturnReason>, :inherit] The return reasons for the category, or `:inherit` to copy
115
+ # them from the closest ancestor that defines its own.
97
116
  # @param parent [Category] The parent category of the category.
98
117
  def initialize(id:, name:, attributes: [], return_reasons: [], parent: nil)
99
118
  @id = id
@@ -101,7 +120,9 @@ module ProductTaxonomy
101
120
  @children = []
102
121
  @secondary_children = []
103
122
  @attributes = attributes
104
- @return_reasons = return_reasons
123
+ @inherits_return_reasons = return_reasons == :inherit
124
+ @defined_return_reasons = @inherits_return_reasons ? [] : return_reasons.dup
125
+ @return_reasons = @defined_return_reasons.dup
105
126
  @parent = parent
106
127
  @secondary_parents = []
107
128
  end
@@ -139,11 +160,29 @@ module ProductTaxonomy
139
160
  @attributes << attribute
140
161
  end
141
162
 
142
- # Add a return reason to the category
163
+ # Add a return reason to the category. Explicitly adding a reason means the category defines its own reasons
164
+ # rather than inheriting them, so the first add on an inheriting category drops the inherited reasons.
143
165
  #
144
166
  # @param [ReturnReason] return_reason
145
167
  def add_return_reason(return_reason)
146
- @return_reasons << return_reason
168
+ if @inherits_return_reasons
169
+ @inherits_return_reasons = false
170
+ @defined_return_reasons = []
171
+ end
172
+ @defined_return_reasons << return_reason
173
+ resolve_return_reasons
174
+ end
175
+
176
+ # Copy return reasons from the closest ancestor that defines its own, when this category inherits. No-op for
177
+ # categories that define their own reasons or have no defining ancestor.
178
+ def resolve_return_reasons
179
+ @return_reasons = if inherits_return_reasons
180
+ ancestors.find { |ancestor| !ancestor.inherits_return_reasons }&.defined_return_reasons&.dup || []
181
+ else
182
+ defined_return_reasons.dup
183
+ end
184
+
185
+ @return_reasons = ReturnReason.global.dup if @return_reasons.empty?
147
186
  end
148
187
 
149
188
  #
@@ -310,6 +349,13 @@ module ProductTaxonomy
310
349
  end
311
350
  end
312
351
 
352
+ def root_does_not_inherit_return_reasons
353
+ return unless inherits_return_reasons
354
+
355
+ # A root category has no ancestor to inherit from, so `inherit` cannot be resolved.
356
+ errors.add(:return_reasons, :root_cannot_inherit, message: "cannot be inherited by a root category")
357
+ end
358
+
313
359
  def children_found?
314
360
  children&.each do |child|
315
361
  next if child.is_a?(Category)
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ # A legally-required product disclosure (e.g. a safety warning or chemical
5
+ # exposure notice). Disclosures form a shallow hierarchy: grouping/root nodes
6
+ # (no `parent_public_id`) organize the axis, and leaf nodes carry the
7
+ # jurisdiction information, plus the optional copy that merchants surface.
8
+ class Disclosure
9
+ include ActiveModel::Validations
10
+ include FormattedValidationErrors
11
+ extend Localized
12
+ extend Indexed
13
+
14
+ # Surfaces on which a disclosure may be displayed.
15
+ SURFACES = ["product_page", "cart", "checkout"].freeze
16
+
17
+ # `public_id` must be a lowercase slug: alphanumeric segments joined by `-` or `_`.
18
+ PUBLIC_ID_FORMAT = /\A[a-z0-9]+(?:[_-][a-z0-9]+)*\z/
19
+
20
+ # String-typed fields validated for type on load.
21
+ STRING_FIELDS = [
22
+ :name,
23
+ :internal_label,
24
+ :description,
25
+ :legal_citation,
26
+ :symbol,
27
+ :display_requirements,
28
+ :title,
29
+ :content,
30
+ :source,
31
+ ].freeze
32
+
33
+ # Fields required on every leaf disclosure, in addition to jurisdictions.
34
+ LEAF_REQUIRED_FIELDS = [:legal_citation, :source].freeze
35
+
36
+ # Fields required on a leaf disclosure that names a display surface.
37
+ DISPLAY_REQUIRED_FIELDS = [:title, :content].freeze
38
+
39
+ class << self
40
+ # Load disclosures from source data. By default this is deserialized from
41
+ # `data/disclosures.yml`.
42
+ #
43
+ # @param source_data [Array<Hash>] The source data to load disclosures from.
44
+ # @return [void]
45
+ def load_from_source(source_data)
46
+ raise ArgumentError, "source_data must be an array" unless source_data.is_a?(Array)
47
+
48
+ source_data.each do |disclosure_data|
49
+ raise ArgumentError, "source_data must contain hashes" unless disclosure_data.is_a?(Hash)
50
+
51
+ disclosure = disclosure_from(disclosure_data)
52
+ Disclosure.add(disclosure)
53
+ disclosure.validate!(:create)
54
+ end
55
+ end
56
+
57
+ # Reset all class-level state.
58
+ def reset
59
+ @localizations = nil
60
+ @hashed_models = nil
61
+ end
62
+
63
+ # Get the next ID for a newly created disclosure.
64
+ #
65
+ # @return [Integer] The next ID.
66
+ def next_id = (all.max_by(&:id)&.id || 0) + 1
67
+
68
+ private
69
+
70
+ def disclosure_from(data)
71
+ Disclosure.new(
72
+ id: data["id"],
73
+ public_id: data["public_id"],
74
+ parent_public_id: data["parent_public_id"],
75
+ name: data["name"],
76
+ internal_label: data["internal_label"],
77
+ description: data["description"],
78
+ jurisdictions: data["jurisdictions"],
79
+ legal_citation: data["legal_citation"],
80
+ symbol: data["symbol"],
81
+ display_requirements: data["display_requirements"],
82
+ display_preferences: data["display_preferences"],
83
+ title: data["title"],
84
+ content: data["content"],
85
+ source: data["source"],
86
+ disclosure_attributes: data["disclosure_attributes"],
87
+ disclosure_attribute_values: data["disclosure_attribute_values"],
88
+ )
89
+ end
90
+ end
91
+
92
+ validates :id, presence: true, numericality: { only_integer: true }, on: :create
93
+ validates :public_id, presence: true, format: { with: PUBLIC_ID_FORMAT, allow_blank: true }, on: :create
94
+ validates :name, presence: true, on: :create
95
+ validates :internal_label, presence: true, on: :create
96
+ validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:public_id, :id], on: :create
97
+ validate :field_types_are_valid, on: :create
98
+ validate :not_self_parenting, on: :create
99
+
100
+ # Validations that can only run once the whole axis is loaded.
101
+ validate :parent_reference_exists, on: :taxonomy_loaded
102
+ validate :hierarchy_is_shallow_and_acyclic, on: :taxonomy_loaded
103
+ validate :jurisdiction_and_display_only_on_leaves, on: :taxonomy_loaded
104
+ validate :leaf_required_fields_present, on: :taxonomy_loaded
105
+ validate :display_fields_are_consistent, on: :taxonomy_loaded
106
+ validate :display_preferences_surfaces_are_valid, on: :taxonomy_loaded
107
+
108
+ localized_attr_reader :name, :description, :title, :content, keyed_by: :public_id
109
+
110
+ attr_reader :id,
111
+ :public_id,
112
+ :parent_public_id,
113
+ :internal_label,
114
+ :jurisdictions,
115
+ :legal_citation,
116
+ :symbol,
117
+ :display_requirements,
118
+ :display_preferences,
119
+ :source,
120
+ :disclosure_attributes,
121
+ :disclosure_attribute_values
122
+
123
+ def initialize(
124
+ id:,
125
+ public_id:,
126
+ name:,
127
+ internal_label:,
128
+ parent_public_id: nil,
129
+ description: nil,
130
+ jurisdictions: nil,
131
+ legal_citation: nil,
132
+ symbol: nil,
133
+ display_requirements: nil,
134
+ display_preferences: nil,
135
+ title: nil,
136
+ content: nil,
137
+ source: nil,
138
+ disclosure_attributes: nil,
139
+ disclosure_attribute_values: nil
140
+ )
141
+ @id = id
142
+ @public_id = public_id
143
+ @parent_public_id = parent_public_id
144
+ @name = name
145
+ @internal_label = internal_label
146
+ @description = description
147
+ @jurisdictions = jurisdictions
148
+ @legal_citation = legal_citation
149
+ @symbol = symbol
150
+ @display_requirements = display_requirements
151
+ @display_preferences = display_preferences
152
+ @title = title
153
+ @content = content
154
+ @source = source
155
+ @disclosure_attributes = disclosure_attributes
156
+ @disclosure_attribute_values = disclosure_attribute_values
157
+ end
158
+
159
+ # The global ID of the disclosure.
160
+ #
161
+ # @return [String]
162
+ def gid
163
+ "gid://shopify/TaxonomyDisclosure/#{id}"
164
+ end
165
+
166
+ # Whether this is a grouping/root node (has no parent).
167
+ #
168
+ # @return [Boolean]
169
+ def root?
170
+ parent_public_id.nil?
171
+ end
172
+
173
+ # The parent disclosure, or nil for root nodes.
174
+ #
175
+ # @return [Disclosure, nil]
176
+ def parent
177
+ parent_public_id && Disclosure.find_by(public_id: parent_public_id)
178
+ end
179
+
180
+ # The direct children of this disclosure.
181
+ #
182
+ # @return [Array<Disclosure>]
183
+ def children
184
+ Disclosure.all.select { |disclosure| disclosure.parent_public_id == public_id }
185
+ end
186
+
187
+ # Whether this is a leaf node (no other disclosure points at it). Leaf nodes
188
+ # are the ones that carry jurisdiction and display information.
189
+ #
190
+ # @return [Boolean]
191
+ def leaf?
192
+ children.empty?
193
+ end
194
+
195
+ private
196
+
197
+ def field_types_are_valid
198
+ errors.add(:jurisdictions, :invalid, message: "must be an array") if jurisdictions && !jurisdictions.is_a?(Array)
199
+ errors.add(:display_preferences, :invalid, message: "must be a hash") if display_preferences && !display_preferences.is_a?(Hash)
200
+ unless disclosure_attributes.nil? || disclosure_attributes.is_a?(Array)
201
+ errors.add(:disclosure_attributes, :invalid, message: "must be an array")
202
+ end
203
+ unless disclosure_attribute_values.nil? || disclosure_attribute_values.is_a?(Array)
204
+ errors.add(:disclosure_attribute_values, :invalid, message: "must be an array")
205
+ end
206
+ STRING_FIELDS.each do |field|
207
+ value = send(field)
208
+ errors.add(field, :invalid, message: "must be a string") if value && !value.is_a?(String)
209
+ end
210
+ end
211
+
212
+ def not_self_parenting
213
+ return if parent_public_id.nil?
214
+
215
+ errors.add(:parent_public_id, :invalid, message: "cannot be its own parent") if parent_public_id == public_id
216
+ end
217
+
218
+ def parent_reference_exists
219
+ return if parent_public_id.nil?
220
+ return if Disclosure.find_by(public_id: parent_public_id)
221
+
222
+ errors.add(:parent_public_id, :not_found, message: "must reference an existing disclosure")
223
+ end
224
+
225
+ # Enforces a two-level axis (grouping/root → leaf) and guards against cycles.
226
+ def hierarchy_is_shallow_and_acyclic
227
+ return if parent_public_id.nil?
228
+
229
+ seen = [public_id]
230
+ current = Disclosure.find_by(public_id: parent_public_id)
231
+ while current
232
+ if seen.include?(current.public_id)
233
+ errors.add(:parent_public_id, :invalid, message: "introduces a cycle in the hierarchy")
234
+ return
235
+ end
236
+ seen << current.public_id
237
+ current = current.parent_public_id && Disclosure.find_by(public_id: current.parent_public_id)
238
+ end
239
+
240
+ parent = Disclosure.find_by(public_id: parent_public_id)
241
+ unless parent.nil? || parent.root?
242
+ errors.add(:parent_public_id, :invalid, message: "must reference a top-level grouping (max depth is two levels)")
243
+ end
244
+ end
245
+
246
+ def jurisdiction_and_display_only_on_leaves
247
+ if leaf?
248
+ errors.add(:jurisdictions, :blank, message: "must be present on leaf disclosures") if jurisdictions.blank?
249
+ else
250
+ errors.add(:jurisdictions, :present, message: "must not be set on grouping disclosures") if jurisdictions.present?
251
+ if display_preferences.present?
252
+ errors.add(:display_preferences, :present, message: "must not be set on grouping disclosures")
253
+ end
254
+ end
255
+ end
256
+
257
+ def leaf_required_fields_present
258
+ return unless leaf?
259
+
260
+ LEAF_REQUIRED_FIELDS.each do |field|
261
+ errors.add(field, :blank, message: "must be present on leaf disclosures") if send(field).blank?
262
+ end
263
+ end
264
+
265
+ # A leaf that names a display surface must carry the copy that buyers read.
266
+ def display_fields_are_consistent
267
+ return unless leaf?
268
+ return if display_preferences.blank?
269
+
270
+ DISPLAY_REQUIRED_FIELDS.each do |field|
271
+ errors.add(field, :blank, message: "must be present when display_preferences is set") if send(field).blank?
272
+ end
273
+ end
274
+
275
+ def display_preferences_surfaces_are_valid
276
+ return if display_preferences.blank?
277
+
278
+ surfaces = display_preferences.is_a?(Hash) ? display_preferences["surfaces"] : nil
279
+ unless surfaces.is_a?(Array) && surfaces.any?
280
+ errors.add(:display_preferences, :invalid, message: "must define a non-empty surfaces array")
281
+ return
282
+ end
283
+
284
+ invalid = surfaces - SURFACES
285
+ errors.add(:display_preferences, :invalid, message: "has invalid surfaces: #{invalid.join(", ")}") if invalid.any?
286
+ errors.add(:display_preferences, :invalid, message: "has duplicate surfaces") if surfaces.uniq.size != surfaces.size
287
+ end
288
+ end
289
+ end
@@ -5,7 +5,13 @@ module ProductTaxonomy
5
5
  def validate!(...)
6
6
  super # Calls original ActiveModel::Validations#validate!
7
7
  rescue ActiveModel::ValidationError
8
- id_field_name = self.is_a?(Category) ? :id : :friendly_id
8
+ id_field_name = if self.is_a?(Category)
9
+ :id
10
+ elsif self.respond_to?(:friendly_id)
11
+ :friendly_id
12
+ else
13
+ :public_id
14
+ end
9
15
  id_value = self.send(id_field_name)
10
16
 
11
17
  formatted_error_details = self.errors.map do |error|
@@ -7,6 +7,17 @@ module ProductTaxonomy
7
7
  extend Localized
8
8
  extend Indexed
9
9
 
10
+ # Reasons that apply to every category, in the order they are always listed in. They sit at the end of a
11
+ # category's list, and stand in for categories that define no reasons of their own.
12
+ GLOBAL_FRIENDLY_IDS = [
13
+ "changed_my_mind",
14
+ "item_not_as_described",
15
+ "received_the_wrong_item",
16
+ "damaged_or_defective",
17
+ "unknown",
18
+ "other_reason",
19
+ ].freeze
20
+
10
21
  class << self
11
22
  # Override to match folder name convention (return_reasons vs returnreasons)
12
23
  def localizations_humanized_model_name
@@ -33,6 +44,15 @@ module ProductTaxonomy
33
44
  def reset
34
45
  @localizations = nil
35
46
  @hashed_models = nil
47
+ @global = nil
48
+ end
49
+
50
+ # The global return reasons, in their defined order. Reasons that are not loaded are skipped, so callers get
51
+ # whatever subset the current source data defines. Frozen because the memoized array is shared by all callers.
52
+ #
53
+ # @return [Array<ReturnReason>]
54
+ def global
55
+ @global ||= GLOBAL_FRIENDLY_IDS.filter_map { |friendly_id| find_by(friendly_id:) }.freeze
36
56
  end
37
57
 
38
58
  # Get the next ID for a newly created return reason.