shopify_product_taxonomy 1.0.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/lib/product_taxonomy/alphanumeric_sorter.rb +113 -0
  3. data/lib/product_taxonomy/identifier_formatter.rb +28 -0
  4. data/lib/product_taxonomy/loader.rb +31 -0
  5. data/lib/product_taxonomy/localizations_validator.rb +31 -0
  6. data/lib/product_taxonomy/models/attribute.rb +170 -0
  7. data/lib/product_taxonomy/models/category.rb +313 -0
  8. data/lib/product_taxonomy/models/extended_attribute.rb +47 -0
  9. data/lib/product_taxonomy/models/integration_version.rb +296 -0
  10. data/lib/product_taxonomy/models/mapping_rule.rb +114 -0
  11. data/lib/product_taxonomy/models/mixins/formatted_validation_errors.rb +23 -0
  12. data/lib/product_taxonomy/models/mixins/indexed.rb +133 -0
  13. data/lib/product_taxonomy/models/mixins/localized.rb +70 -0
  14. data/lib/product_taxonomy/models/serializers/attribute/data/data_serializer.rb +48 -0
  15. data/lib/product_taxonomy/models/serializers/attribute/data/localizations_serializer.rb +34 -0
  16. data/lib/product_taxonomy/models/serializers/attribute/dist/json_serializer.rb +41 -0
  17. data/lib/product_taxonomy/models/serializers/attribute/dist/txt_serializer.rb +42 -0
  18. data/lib/product_taxonomy/models/serializers/attribute/docs/base_and_extended_serializer.rb +41 -0
  19. data/lib/product_taxonomy/models/serializers/attribute/docs/reversed_serializer.rb +55 -0
  20. data/lib/product_taxonomy/models/serializers/attribute/docs/search_serializer.rb +32 -0
  21. data/lib/product_taxonomy/models/serializers/category/data/data_serializer.rb +29 -0
  22. data/lib/product_taxonomy/models/serializers/category/data/full_names_serializer.rb +26 -0
  23. data/lib/product_taxonomy/models/serializers/category/data/localizations_serializer.rb +34 -0
  24. data/lib/product_taxonomy/models/serializers/category/dist/json_serializer.rb +60 -0
  25. data/lib/product_taxonomy/models/serializers/category/dist/txt_serializer.rb +42 -0
  26. data/lib/product_taxonomy/models/serializers/category/docs/search_serializer.rb +33 -0
  27. data/lib/product_taxonomy/models/serializers/category/docs/siblings_serializer.rb +39 -0
  28. data/lib/product_taxonomy/models/serializers/value/data/data_serializer.rb +28 -0
  29. data/lib/product_taxonomy/models/serializers/value/data/localizations_serializer.rb +34 -0
  30. data/lib/product_taxonomy/models/serializers/value/dist/json_serializer.rb +31 -0
  31. data/lib/product_taxonomy/models/serializers/value/dist/txt_serializer.rb +38 -0
  32. data/lib/product_taxonomy/models/taxonomy.rb +11 -0
  33. data/lib/product_taxonomy/models/value.rb +147 -0
  34. data/lib/product_taxonomy/version.rb +6 -0
  35. data/lib/product_taxonomy.rb +50 -0
  36. metadata +124 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ class ExtendedAttribute < Attribute
5
+ class << self
6
+ def localizations
7
+ superclass.localizations # Extended attribute localizations are defined in the same place as attributes
8
+ end
9
+ end
10
+
11
+ validate :values_from_valid?
12
+
13
+ attr_reader :values_from
14
+
15
+ alias_method :base_attribute, :values_from
16
+
17
+ # @param name [String] The name of the attribute.
18
+ # @param handle [String] The handle of the attribute.
19
+ # @param description [String] The description of the attribute.
20
+ # @param friendly_id [String] The friendly ID of the attribute.
21
+ # @param values_from [Attribute, String] A resolved {Attribute} object. When resolving fails, pass the friendly ID
22
+ # instead.
23
+ def initialize(name:, handle:, description:, friendly_id:, values_from:)
24
+ @values_from = values_from
25
+ values_from.add_extended_attribute(self) if values_from.is_a?(Attribute)
26
+ super(
27
+ id: values_from.try(:id),
28
+ name:,
29
+ handle:,
30
+ description:,
31
+ friendly_id:,
32
+ values: values_from.try(:values),
33
+ is_manually_sorted: values_from.try(:manually_sorted?) || false,
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def values_from_valid?
40
+ errors.add(
41
+ :base_attribute,
42
+ :not_found,
43
+ message: "not found for friendly ID \"#{values_from}\"",
44
+ ) unless values_from.is_a?(Attribute)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ # A single version of an integration, e.g. "shopify/2024-07".
5
+ #
6
+ # Includes:
7
+ # - The full names of categories in the integration's taxonomy.
8
+ # - The mapping rules for converting between the integration's taxonomy and Shopify's taxonomy.
9
+ class IntegrationVersion
10
+ INTEGRATIONS_PATH = File.expand_path("integrations", ProductTaxonomy.data_path)
11
+
12
+ class << self
13
+ # Generate all distribution files for all integration versions.
14
+ #
15
+ # @param output_path [String] The path to the output directory.
16
+ # @param logger [Logger] The logger to use for logging messages.
17
+ # @param current_shopify_version [String] The current version of the Shopify taxonomy.
18
+ # @param base_path [String] The path to the base directory containing integration versions.
19
+ def generate_all_distributions(output_path:, logger:, current_shopify_version: nil, base_path: INTEGRATIONS_PATH)
20
+ clear_shopify_integrations_directory(output_path:, logger:)
21
+
22
+ integration_versions = load_all_from_source(current_shopify_version:, base_path:)
23
+ all_mappings = integration_versions.each_with_object([]) do |integration_version, all_mappings|
24
+ logger.info("Generating integration mappings for #{integration_version.name}/#{integration_version.version}")
25
+ integration_version.generate_distributions(output_path:)
26
+ all_mappings.concat(integration_version.to_json(direction: :both))
27
+ end
28
+ generate_all_mappings_file(mappings: all_mappings, current_shopify_version:, output_path:)
29
+ end
30
+
31
+ # Clear the Shopify integrations directory before generating new files.
32
+ #
33
+ # @param output_path [String] The path to the output directory.
34
+ # @param logger [Logger] The logger to use for logging messages.
35
+ def clear_shopify_integrations_directory(output_path:, logger:)
36
+ shopify_integrations_path = File.join(integrations_output_path(output_path), "shopify")
37
+ if Dir.exist?(shopify_integrations_path)
38
+ FileUtils.rm_rf(shopify_integrations_path)
39
+ FileUtils.mkdir_p(shopify_integrations_path)
40
+ end
41
+ end
42
+
43
+ # Load all integration versions from the source data directory.
44
+ #
45
+ # @return [Array<IntegrationVersion>]
46
+ def load_all_from_source(current_shopify_version: nil, base_path: INTEGRATIONS_PATH)
47
+ integrations_yaml = YAML.safe_load_file(File.expand_path("integrations.yml", base_path))
48
+ integrations_yaml.flat_map do |integration_yaml|
49
+ versions = integration_yaml["available_versions"].sort.map do |version_path|
50
+ load_from_source(
51
+ integration_path: File.expand_path(version_path, base_path),
52
+ current_shopify_version:,
53
+ )
54
+ end
55
+
56
+ resolve_to_shopify_mappings_chain(versions) if integration_yaml["name"] == "shopify"
57
+
58
+ versions
59
+ end
60
+ end
61
+
62
+ # Resolve a set of IntegrationVersion to_shopify mappings so that each one maps to the latest version of the
63
+ # Shopify taxonomy.
64
+ #
65
+ # @param versions [Array<IntegrationVersion>] The versions to resolve, ordered from oldest to newest.
66
+ def resolve_to_shopify_mappings_chain(versions)
67
+ versions.reverse.each_with_object([]) do |version, resolved_versions|
68
+ version.resolve_to_shopify_mappings(resolved_versions)
69
+ resolved_versions.prepend(version)
70
+ end
71
+ end
72
+
73
+ # Load an integration version from the provided source data directory.
74
+ #
75
+ # @param integration_path [String] The path to the integration version source data directory.
76
+ # @return [IntegrationVersion]
77
+ def load_from_source(integration_path:, current_shopify_version: nil)
78
+ full_names = YAML.safe_load_file(File.expand_path("full_names.yml", integration_path))
79
+ full_names_by_id = full_names.each_with_object({}) { |data, hash| hash[data["id"].to_s] = data }
80
+
81
+ from_shopify_mappings = MappingRule.load_rules_from_source(
82
+ integration_path:,
83
+ direction: :from_shopify,
84
+ full_names_by_id:,
85
+ )
86
+ to_shopify_mappings = MappingRule.load_rules_from_source(
87
+ integration_path:,
88
+ direction: :to_shopify,
89
+ full_names_by_id:,
90
+ )
91
+
92
+ integration_pathname = Pathname.new(integration_path)
93
+
94
+ new(
95
+ name: integration_pathname.parent.basename.to_s,
96
+ version: integration_pathname.basename.to_s,
97
+ full_names_by_id:,
98
+ from_shopify_mappings:,
99
+ to_shopify_mappings:,
100
+ current_shopify_version:,
101
+ )
102
+ end
103
+
104
+ # Generate a JSON file containing all mappings for all integration versions.
105
+ #
106
+ # @param mappings [Array<Hash>] The mappings to include in the file.
107
+ # @param version [String] The current version of the Shopify taxonomy.
108
+ # @param output_path [String] The path to the output directory.
109
+ def generate_all_mappings_file(mappings:, current_shopify_version:, output_path:)
110
+ File.write(
111
+ File.expand_path("all_mappings.json", integrations_output_path(output_path)),
112
+ JSON.pretty_generate(to_json(mappings:, current_shopify_version:)) + "\n",
113
+ )
114
+ end
115
+
116
+ # Generate a JSON representation for a given set of mappings and version of the Shopify taxonomy.
117
+ #
118
+ # @param version [String] The current version of the Shopify taxonomy.
119
+ # @param mappings [Array<Hash>] The mappings to include in the file.
120
+ # @return [Hash]
121
+ def to_json(current_shopify_version:, mappings:)
122
+ {
123
+ version: current_shopify_version,
124
+ mappings:,
125
+ }
126
+ end
127
+
128
+ # Generate the path to the integrations output directory.
129
+ #
130
+ # @param base_output_path [String] The base path to the output directory.
131
+ # @return [String]
132
+ def integrations_output_path(base_output_path)
133
+ File.expand_path("en/integrations", base_output_path)
134
+ end
135
+ end
136
+
137
+ attr_reader :name, :version, :from_shopify_mappings, :to_shopify_mappings, :full_names_by_id
138
+
139
+ # @param name [String] The name of the integration.
140
+ # @param version [String] The version of the integration.
141
+ # @param full_names_by_id [Hash<String, Hash>] A hash of full names by ID.
142
+ # @param current_shopify_version [String] The current version of the Shopify taxonomy.
143
+ # @param from_shopify_mappings [Array<MappingRule>] The mappings from the Shopify taxonomy to the integration's
144
+ # taxonomy.
145
+ # @param to_shopify_mappings [Array<MappingRule>] The mappings from the integration's taxonomy to the Shopify
146
+ # taxonomy.
147
+ def initialize(
148
+ name:,
149
+ version:,
150
+ full_names_by_id:,
151
+ current_shopify_version: nil,
152
+ from_shopify_mappings: nil,
153
+ to_shopify_mappings: nil
154
+ )
155
+ @name = name
156
+ @version = version
157
+ @full_names_by_id = full_names_by_id
158
+ @current_shopify_version = current_shopify_version
159
+ @from_shopify_mappings = from_shopify_mappings
160
+ @to_shopify_mappings = to_shopify_mappings
161
+ @to_json = {} # memoized by direction
162
+ end
163
+
164
+ # Generate all distribution files for the integration version.
165
+ #
166
+ # @param output_path [String] The path to the output directory.
167
+ def generate_distributions(output_path:)
168
+ generate_distribution(output_path:, direction: :from_shopify) if @from_shopify_mappings.present?
169
+ generate_distribution(output_path:, direction: :to_shopify) if @to_shopify_mappings.present?
170
+ end
171
+
172
+ # Generate JSON and TXT distribution files for a single direction of the integration version.
173
+ #
174
+ # @param output_path [String] The path to the output directory.
175
+ # @param direction [Symbol] The direction of the distribution file to generate (:from_shopify or :to_shopify).
176
+ def generate_distribution(output_path:, direction:)
177
+ output_dir = File.expand_path(@name, self.class.integrations_output_path(output_path))
178
+ FileUtils.mkdir_p(output_dir)
179
+
180
+ json = self.class.to_json(mappings: [to_json(direction:)], current_shopify_version: @current_shopify_version)
181
+ File.write(
182
+ File.expand_path("#{distribution_filename(direction:)}.json", output_dir),
183
+ JSON.pretty_generate(json) + "\n",
184
+ )
185
+ File.write(
186
+ File.expand_path("#{distribution_filename(direction:)}.txt", output_dir),
187
+ to_txt(direction:) + "\n",
188
+ )
189
+ end
190
+
191
+ # Resolve the output categories of to_shopify mappings to the current version of the Shopify taxonomy, taking into
192
+ # any mappings from later versions.
193
+ #
194
+ # @param next_integration_versions [Array<IntegrationVersion>] An array of Shopify integration versions coming
195
+ # after the current version.
196
+ def resolve_to_shopify_mappings(next_integration_versions)
197
+ @to_shopify_mappings&.each do |mapping|
198
+ newer_mapping = next_integration_versions.flat_map(&:to_shopify_mappings).compact.find do |mapping_rule|
199
+ mapping_rule.input_category["id"] == mapping.output_category
200
+ end
201
+ mapping.output_category = newer_mapping&.output_category || Category.find_by(id: mapping.output_category)
202
+
203
+ next unless mapping.output_category.nil?
204
+
205
+ raise ArgumentError, "Failed to resolve Shopify mapping: " \
206
+ "\"#{mapping.input_category["id"]}\" to \"#{mapping.output_category}\" " \
207
+ "(input version: #{version})"
208
+ end
209
+ end
210
+
211
+ # For a mapping to an external taxonomy, get the IDs of external categories that are not mapped from Shopify.
212
+ #
213
+ # @return [Array<String>] IDs of external categories not mapped from the Shopify taxonomy. Empty if there are no
214
+ # mappings from Shopify.
215
+ def unmapped_external_category_ids
216
+ return [] if @from_shopify_mappings.blank?
217
+
218
+ mappings_by_output_category_id = @from_shopify_mappings.index_by { _1.output_category["id"].to_s }
219
+ @full_names_by_id.keys - mappings_by_output_category_id.keys
220
+ end
221
+
222
+ # Generate a JSON representation of the integration version for a single direction.
223
+ #
224
+ # @param direction [Symbol] The direction of the distribution file to generate (:from_shopify or :to_shopify).
225
+ # @return [Hash, Array<Hash>, nil]
226
+ def to_json(direction:)
227
+ if @to_json.key?(direction)
228
+ @to_json[direction]
229
+ elsif direction == :both
230
+ [to_json(direction: :from_shopify), to_json(direction: :to_shopify)].compact
231
+ else
232
+ mappings = if direction == :from_shopify
233
+ @from_shopify_mappings&.sort_by { _1.input_category.id_parts }
234
+ else
235
+ @to_shopify_mappings
236
+ end
237
+
238
+ @to_json[direction] = if mappings.present?
239
+ {
240
+ input_taxonomy: input_name_and_version(direction:),
241
+ output_taxonomy: output_name_and_version(direction:),
242
+ rules: mappings.map(&:to_json),
243
+ }
244
+ end
245
+ end
246
+ end
247
+
248
+ # Generate a TXT representation of the integration version for a single direction.
249
+ #
250
+ # @param direction [Symbol] The direction of the distribution file to generate (:from_shopify or :to_shopify).
251
+ # @return [String]
252
+ def to_txt(direction:)
253
+ mappings = direction == :from_shopify ? @from_shopify_mappings : @to_shopify_mappings
254
+
255
+ header = <<~TXT
256
+ # Shopify Product Taxonomy - Mapping #{input_name_and_version(direction:)} to #{output_name_and_version(direction:)}
257
+ # Format:
258
+ # → {base taxonomy category name}
259
+ # ⇒ {mapped taxonomy category name}
260
+
261
+ TXT
262
+
263
+ visible_mappings = mappings.filter_map do |mapping|
264
+ next if @name == "shopify" && direction == :to_shopify && mapping.input_txt_equals_output_txt?
265
+
266
+ mapping.to_txt
267
+ end
268
+
269
+ header + visible_mappings.sort.join("\n").chomp
270
+ end
271
+
272
+ private
273
+
274
+ def distribution_filename(direction:)
275
+ "#{input_name_and_version(direction:)}_to_#{output_name_and_version(direction:)}"
276
+ .gsub("/", "_")
277
+ .gsub("-unstable", "")
278
+ end
279
+
280
+ def integration_name_and_version
281
+ "#{@name}/#{@version}"
282
+ end
283
+
284
+ def shopify_name_and_version
285
+ "shopify/#{@current_shopify_version}"
286
+ end
287
+
288
+ def input_name_and_version(direction:)
289
+ direction == :from_shopify ? shopify_name_and_version : integration_name_and_version
290
+ end
291
+
292
+ def output_name_and_version(direction:)
293
+ direction == :from_shopify ? integration_name_and_version : shopify_name_and_version
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ # A mapping rule for converting between the integration's taxonomy and Shopify's taxonomy.
5
+ class MappingRule
6
+ class << self
7
+ # Load mapping rules from the provided source data directory for a single direction.
8
+ #
9
+ # @param integration_path [String] The path to the integration version source data directory.
10
+ # @param direction [Symbol] The direction of the mapping rules to load (:from_shopify or :to_shopify).
11
+ # @param full_names_by_id [Hash<String, Hash>] A hash of full names by ID.
12
+ # @return [Array<MappingRule>, nil]
13
+ def load_rules_from_source(integration_path:, direction:, full_names_by_id:)
14
+ file_path = File.expand_path("mappings/#{direction}.yml", integration_path)
15
+ return unless File.exist?(file_path)
16
+
17
+ data = YAML.safe_load_file(file_path)
18
+ raise ArgumentError, "Mapping rules file does not contain a hash: #{file_path}" unless data.is_a?(Hash)
19
+ raise ArgumentError, "Mapping rules file does not have a `rules` key: #{file_path}" unless data.key?("rules")
20
+ unless data["rules"].is_a?(Array)
21
+ raise ArgumentError, "Mapping rules file `rules` value is not an array: #{file_path}"
22
+ end
23
+
24
+ data["rules"].map do |rule|
25
+ input_id = rule.dig("input", "product_category_id")&.to_s
26
+ output_id = rule.dig("output", "product_category_id")&.first&.to_s
27
+
28
+ raise ArgumentError, "Invalid mapping rule: #{rule}" if input_id.nil? || output_id.nil?
29
+
30
+ is_to_shopify = direction == :to_shopify
31
+ input_category = is_to_shopify ? full_names_by_id[input_id] : Category.find_by(id: input_id)
32
+ # We set output_category to be the raw output ID if is_to_shopify is true, with an expectation that it will
33
+ # be resolved to a `Category` before the rule is serialized to JSON or TXT.
34
+ # See `IntegrationVersion#resolve_to_shopify_mappings`.
35
+ output_category = is_to_shopify ? output_id : full_names_by_id[output_id]
36
+
37
+ raise ArgumentError, "Input category not found for mapping rule: #{rule}" unless input_category
38
+ raise ArgumentError, "Output category not found for mapping rule: #{rule}" unless output_category
39
+
40
+ new(input_category:, output_category:)
41
+ rescue TypeError, NoMethodError
42
+ raise ArgumentError, "Invalid mapping rule: #{rule}"
43
+ end
44
+ end
45
+ end
46
+
47
+ attr_reader :input_category
48
+ attr_accessor :output_category
49
+
50
+ def initialize(input_category:, output_category:)
51
+ @input_category = input_category
52
+ @output_category = output_category
53
+ end
54
+
55
+ # Generate a JSON representation of the mapping rule.
56
+ #
57
+ # @return [Hash]
58
+ def to_json
59
+ {
60
+ input: {
61
+ category: category_json(@input_category),
62
+ },
63
+ output: {
64
+ category: [category_json(@output_category)],
65
+ },
66
+ }
67
+ end
68
+
69
+ # Generate a TXT representation of the mapping rule.
70
+ #
71
+ # @return [String]
72
+ def to_txt
73
+ <<~TXT
74
+ → #{category_txt(@input_category)}
75
+ ⇒ #{category_txt(@output_category)}
76
+ TXT
77
+ end
78
+
79
+ # Whether the input and output categories have the same full name.
80
+ #
81
+ # @return [Boolean]
82
+ def input_txt_equals_output_txt?
83
+ category_txt(@input_category) == category_txt(@output_category)
84
+ end
85
+
86
+ private
87
+
88
+ def category_json(category)
89
+ if category.is_a?(Hash)
90
+ {
91
+ id: category["id"].to_s,
92
+ full_name: category["full_name"],
93
+ }
94
+ elsif category.is_a?(Category)
95
+ {
96
+ id: category.gid,
97
+ full_name: category.full_name,
98
+ }
99
+ else
100
+ raise ArgumentError, "Mapping rule category not resolved. Raw value: #{category}"
101
+ end
102
+ end
103
+
104
+ def category_txt(category)
105
+ if category.is_a?(Hash)
106
+ category["full_name"]
107
+ elsif category.is_a?(Category)
108
+ category.full_name
109
+ else
110
+ raise ArgumentError, "Mapping rule category not resolved. Raw value: #{category}"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ module FormattedValidationErrors
5
+ def validate!(...)
6
+ super # Calls original ActiveModel::Validations#validate!
7
+ rescue ActiveModel::ValidationError
8
+ id_field_name = self.is_a?(Category) ? :id : :friendly_id
9
+ id_value = self.send(id_field_name)
10
+
11
+ formatted_error_details = self.errors.map do |error|
12
+ attribute = error.attribute # Raw attribute name, e.g., :friendly_id
13
+ message = error.message # Just the message part, e.g., "can't be blank"
14
+ prefix = " • "
15
+
16
+ prefix + (attribute == :base ? message : "#{attribute} #{message}")
17
+ end.join("\n")
18
+
19
+ raise ActiveModel::ValidationError.new(self), "Validation failed for #{self.class.name.demodulize.downcase} " \
20
+ "with #{id_field_name}=`#{id_value}`:\n#{formatted_error_details}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ # Mixin providing indexing for a model, using hashes to support fast uniqueness checks and lookups by field value.
5
+ module Indexed
6
+ NotFoundError = Class.new(StandardError)
7
+
8
+ class << self
9
+ # Keep track of which model class used "extend Indexed" so that the model can be subclassed while still storing
10
+ # model objects in a shared index on the superclass.
11
+ def extended(indexed_model_class)
12
+ indexed_model_class.instance_variable_set(:@is_indexed, true)
13
+ end
14
+ end
15
+
16
+ # Validator that checks for uniqueness of a field value across all models in a given index.
17
+ class UniquenessValidator < ActiveModel::EachValidator
18
+ def validate_each(record, attribute, value)
19
+ if record.class.duplicate?(model: record, field: attribute)
20
+ record.errors.add(attribute, :taken, message: "\"#{value}\" has already been used")
21
+ end
22
+ end
23
+ end
24
+
25
+ # Add a model to the index.
26
+ #
27
+ # @param model [Object] The model to add to the index.
28
+ def add(model)
29
+ hashed_models.keys.each do |field|
30
+ hashed_models[field][model.send(field)] ||= []
31
+ hashed_models[field][model.send(field)] << model
32
+ end
33
+ end
34
+
35
+ # Convenience method to create a model, validate it, and add it to the index. If the model is not valid, raise an
36
+ # error.
37
+ #
38
+ # @param args [Array] The arguments to pass to the model's constructor.
39
+ # @return [Object] The created model.
40
+ def create_validate_and_add!(...)
41
+ model = new(...)
42
+ model.validate!(:create)
43
+ add(model)
44
+ model
45
+ end
46
+
47
+ # Check if a field value is a duplicate of an existing model. A model is considered to be a duplicate if it was
48
+ # added after the first model with that value.
49
+ #
50
+ # @param model [Object] The model to check for uniqueness.
51
+ # @param field [Symbol] The field to check for uniqueness.
52
+ # @return [Boolean] Whether the value is unique.
53
+ def duplicate?(model:, field:)
54
+ raise ArgumentError, "Field not hashed: #{field}" unless hashed_models.key?(field)
55
+
56
+ existing_models = hashed_models[field][model.send(field)]
57
+ return false if existing_models.nil?
58
+
59
+ existing_models.first != model
60
+ end
61
+
62
+ # Find a model by field value. Returns the first matching record or nil. Only works for fields marked unique.
63
+ #
64
+ # @param conditions [Hash] Hash of field-value pairs to search by
65
+ # @return [Object, nil] The matching model or nil if not found
66
+ def find_by(**conditions)
67
+ field, value = conditions.first
68
+ raise ArgumentError, "Field not hashed: #{field}" unless hashed_models.key?(field)
69
+
70
+ hashed_models[field][value]&.first
71
+ end
72
+
73
+ # Find a model by field value. Returns the first matching record or raises an error if not found. Only works for
74
+ # fields marked unique.
75
+ #
76
+ # @param conditions [Hash] Hash of field-value pairs to search by
77
+ # @return [Object] The matching model
78
+ def find_by!(**conditions)
79
+ record = find_by(**conditions)
80
+ return record if record
81
+
82
+ field, value = conditions.first
83
+ field = field.to_s.humanize(capitalize: false, keep_id_suffix: true)
84
+ raise NotFoundError, "#{self.name.demodulize} with #{field} \"#{value}\" not found"
85
+ end
86
+
87
+ # Get the hash of models indexed by a given field. Only works for fields marked unique.
88
+ #
89
+ # @param field [Symbol] The field to get the hash for.
90
+ # @return [Hash] The hash of models indexed by the given field.
91
+ def hashed_by(field)
92
+ raise ArgumentError, "Field not hashed: #{field}" unless hashed_models.key?(field)
93
+
94
+ hashed_models[field]
95
+ end
96
+
97
+ # Get all models in the index.
98
+ #
99
+ # @return [Array<Object>] All models in the index.
100
+ def all
101
+ hashed_models.first[1].values.flatten
102
+ end
103
+
104
+ # Get the number of models in the index.
105
+ #
106
+ # @return [Integer] The number of models in the index.
107
+ def size
108
+ all.size
109
+ end
110
+
111
+ # Get the hash of hash of models, indexed by fields marked unique.
112
+ #
113
+ # @return [Hash] The hash of hash of models indexed by the fields marked unique.
114
+ def hashed_models
115
+ # If we're a subclass of the model class that originally extended Indexed, use the parent class' hashed_models.
116
+ return superclass.hashed_models unless @is_indexed
117
+
118
+ @hashed_models ||= unique_fields.each_with_object({}) do |field, hash|
119
+ hash[field] = {}
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def unique_fields
126
+ _validators.select do |_, validators|
127
+ validators.any? do |validator|
128
+ validator.is_a?(UniquenessValidator)
129
+ end
130
+ end.keys
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ module Localized
5
+ def localizations
6
+ return @localizations if @localizations
7
+
8
+ localization_path = File.expand_path(
9
+ "localizations/#{localizations_humanized_model_name}/*.yml",
10
+ ProductTaxonomy.data_path,
11
+ )
12
+ @localizations = Dir.glob(localization_path).each_with_object({}) do |file, localizations|
13
+ locale = File.basename(file, ".yml")
14
+ localizations[locale] = YAML.safe_load_file(file).dig(locale, localizations_humanized_model_name)
15
+ end
16
+ end
17
+
18
+ # Validate that all localizations are present for the given locales. If no locales are provided, all locales
19
+ # will be validated.
20
+ #
21
+ # @param locales [Array<String>, nil] The locales to validate. If nil, all locales will be validated.
22
+ def validate_localizations!(locales = nil)
23
+ model_keys = all.map { |model| model.send(@localizations_keyed_by).to_s }
24
+ locales_to_validate = locales || localizations.keys
25
+ locales_to_validate.each do |locale|
26
+ missing_localization_keys = model_keys.reject { |key| localizations.dig(locale, key, "name") }
27
+ next if missing_localization_keys.empty?
28
+
29
+ raise ArgumentError, "Missing or incomplete localizations for the following keys in " \
30
+ "localizations/#{localizations_humanized_model_name}/#{locale}.yml: #{missing_localization_keys.join(", ")}"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Define methods that return localized attributes by fetching from localization YAML files. Values for the `en`
37
+ # locale come directly from the model's attributes.
38
+ #
39
+ # For example, if the class localizes `name` and `description` attributes keyed by `friendly_id`:
40
+ #
41
+ # localized_attr_reader :name, :description, keyed_by: :friendly_id
42
+ #
43
+ # This will generate the following methods:
44
+ #
45
+ # name(locale: "en")
46
+ # description(locale: "en")
47
+ def localized_attr_reader(*attrs, keyed_by: :friendly_id)
48
+ if @localizations_keyed_by.present? && @localizations_keyed_by != keyed_by
49
+ raise ArgumentError, "Cannot localize attributes with different keyed_by values"
50
+ end
51
+
52
+ @localizations_keyed_by = keyed_by
53
+ attrs.each do |attr|
54
+ define_method(attr) do |locale: "en"|
55
+ raw_value = instance_variable_get("@#{attr}")
56
+
57
+ if locale == "en"
58
+ raw_value
59
+ else
60
+ self.class.localizations.dig(locale, send(keyed_by).to_s, attr.to_s) || raw_value
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ def localizations_humanized_model_name
67
+ name.demodulize.humanize.downcase.pluralize
68
+ end
69
+ end
70
+ end