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.
- checksums.yaml +7 -0
- data/lib/product_taxonomy/alphanumeric_sorter.rb +113 -0
- data/lib/product_taxonomy/identifier_formatter.rb +28 -0
- data/lib/product_taxonomy/loader.rb +31 -0
- data/lib/product_taxonomy/localizations_validator.rb +31 -0
- data/lib/product_taxonomy/models/attribute.rb +170 -0
- data/lib/product_taxonomy/models/category.rb +313 -0
- data/lib/product_taxonomy/models/extended_attribute.rb +47 -0
- data/lib/product_taxonomy/models/integration_version.rb +296 -0
- data/lib/product_taxonomy/models/mapping_rule.rb +114 -0
- data/lib/product_taxonomy/models/mixins/formatted_validation_errors.rb +23 -0
- data/lib/product_taxonomy/models/mixins/indexed.rb +133 -0
- data/lib/product_taxonomy/models/mixins/localized.rb +70 -0
- data/lib/product_taxonomy/models/serializers/attribute/data/data_serializer.rb +48 -0
- data/lib/product_taxonomy/models/serializers/attribute/data/localizations_serializer.rb +34 -0
- data/lib/product_taxonomy/models/serializers/attribute/dist/json_serializer.rb +41 -0
- data/lib/product_taxonomy/models/serializers/attribute/dist/txt_serializer.rb +42 -0
- data/lib/product_taxonomy/models/serializers/attribute/docs/base_and_extended_serializer.rb +41 -0
- data/lib/product_taxonomy/models/serializers/attribute/docs/reversed_serializer.rb +55 -0
- data/lib/product_taxonomy/models/serializers/attribute/docs/search_serializer.rb +32 -0
- data/lib/product_taxonomy/models/serializers/category/data/data_serializer.rb +29 -0
- data/lib/product_taxonomy/models/serializers/category/data/full_names_serializer.rb +26 -0
- data/lib/product_taxonomy/models/serializers/category/data/localizations_serializer.rb +34 -0
- data/lib/product_taxonomy/models/serializers/category/dist/json_serializer.rb +60 -0
- data/lib/product_taxonomy/models/serializers/category/dist/txt_serializer.rb +42 -0
- data/lib/product_taxonomy/models/serializers/category/docs/search_serializer.rb +33 -0
- data/lib/product_taxonomy/models/serializers/category/docs/siblings_serializer.rb +39 -0
- data/lib/product_taxonomy/models/serializers/value/data/data_serializer.rb +28 -0
- data/lib/product_taxonomy/models/serializers/value/data/localizations_serializer.rb +34 -0
- data/lib/product_taxonomy/models/serializers/value/dist/json_serializer.rb +31 -0
- data/lib/product_taxonomy/models/serializers/value/dist/txt_serializer.rb +38 -0
- data/lib/product_taxonomy/models/taxonomy.rb +11 -0
- data/lib/product_taxonomy/models/value.rb +147 -0
- data/lib/product_taxonomy/version.rb +6 -0
- data/lib/product_taxonomy.rb +50 -0
- 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
|