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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5cec4d780841db87dfa606e3b36510bbcb2398f03a6cf091f1a1867e0b88b8b0
|
|
4
|
+
data.tar.gz: 3142307e81c2049fedf3fcf38d7ba87bb2594b4a88185aadfb1cb7dceac6c2b3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fb4fbe719cd799f493b7da19f6be9e2c12f776a136a3062a776645fb680cc96fbf4eef2add7eb6ea939c6a76b1c30af90888ce00f23e5633d71cfd571720ae35
|
|
7
|
+
data.tar.gz: 1a272445bdd6bb760cae6e4ac2538a58de1f8588a857ab291a32ab450661fde810e1c26223eeb1b28bf105dd2a2f182655fba1372d6e79ba6d0f41e6dc2374b3
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
module AlphanumericSorter
|
|
5
|
+
class << self
|
|
6
|
+
def sort(values, other_last: false)
|
|
7
|
+
values.sort_by.with_index do |value, idx|
|
|
8
|
+
[
|
|
9
|
+
other_last && value.to_s.downcase == "other" ? 1 : 0,
|
|
10
|
+
*normalize_value(value),
|
|
11
|
+
idx,
|
|
12
|
+
]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def normalize_value(value)
|
|
17
|
+
@normalized_values ||= {}
|
|
18
|
+
@normalized_values[value] ||= if (numerical = value.match(RegexPattern::NUMERIC_PATTERN))
|
|
19
|
+
[0, *normalize_numerical(numerical)]
|
|
20
|
+
elsif (sequential = value.match(RegexPattern::SEQUENTIAL_TEXT_PATTERN))
|
|
21
|
+
[1, *normalize_sequential(sequential)]
|
|
22
|
+
else
|
|
23
|
+
[1, normalize_text(value)]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def normalize_numerical(match)
|
|
30
|
+
[
|
|
31
|
+
normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
|
|
32
|
+
normalize_text(match[:seperator]) || "-",
|
|
33
|
+
normalize_single_number(match[:primary_number]),
|
|
34
|
+
normalize_single_number(match[:secondary_number]),
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_sequential(match)
|
|
39
|
+
[
|
|
40
|
+
normalize_text(match[:primary_text]),
|
|
41
|
+
normalize_single_number(match[:primary_step]),
|
|
42
|
+
normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
|
|
43
|
+
normalize_text(match[:seperator]) || "-",
|
|
44
|
+
normalize_text(match[:secondary_text]),
|
|
45
|
+
normalize_single_number(match[:secondary_step]),
|
|
46
|
+
normalize_text(match[:trailing_text]),
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_single_number(value)
|
|
51
|
+
value = value.split.sum(&:to_r) if value&.include?("/")
|
|
52
|
+
value.to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_text(value)
|
|
56
|
+
return if value.nil?
|
|
57
|
+
|
|
58
|
+
ActiveSupport::Inflector.transliterate(value.strip.downcase)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module RegexPattern
|
|
63
|
+
# matches numbers like -1, 5, 10.5, 3/4, 2 5/8
|
|
64
|
+
SINGLE_NUMBER = %r{
|
|
65
|
+
-? # Optional negative sign
|
|
66
|
+
(?:
|
|
67
|
+
\d+\.?\d* # Easy numbers like 5, 10.5
|
|
68
|
+
|
|
|
69
|
+
(?:\d+\s)?\d+/[1-9]+\d* # Fractions like 3/4, 2 5/8
|
|
70
|
+
)
|
|
71
|
+
}x
|
|
72
|
+
|
|
73
|
+
# matches units like sq.ft, km/h
|
|
74
|
+
UNITS_OF_MEASURE = %r{
|
|
75
|
+
[^\d\./\-] # Matches any character not a digit, dot, slash or dash
|
|
76
|
+
[^\-\d]* # Matches any character not a dash or digit
|
|
77
|
+
}x
|
|
78
|
+
|
|
79
|
+
# String capturing is simple
|
|
80
|
+
BASIC_TEXT = /\D+/
|
|
81
|
+
SEPERATOR = /[\p{Pd}x~]/
|
|
82
|
+
|
|
83
|
+
# NUMERIC_PATTERN matches a primary number with optional units, and an optional range or dimension
|
|
84
|
+
# with a secondary number and its optional units.
|
|
85
|
+
NUMERIC_PATTERN = %r{
|
|
86
|
+
^\s*(?<primary_number>#{SINGLE_NUMBER}) # 1. Primary number
|
|
87
|
+
\s*(?<primary_unit>#{UNITS_OF_MEASURE})? # 2. Optional units for primary number
|
|
88
|
+
(?: # Optional range or dimension
|
|
89
|
+
\s*(?<seperator>#{SEPERATOR}) # 3. Separator
|
|
90
|
+
\s*(?<secondary_number>#{SINGLE_NUMBER}) # 4. Secondary number
|
|
91
|
+
\s*(?<secondary_unit>#{UNITS_OF_MEASURE})? # 5. Optional units for secondary number
|
|
92
|
+
)?
|
|
93
|
+
\s*$
|
|
94
|
+
}x
|
|
95
|
+
|
|
96
|
+
# SEQUENTIAL_TEXT_PATTERN matches a primary non-number string, an optional step, and optional units,
|
|
97
|
+
# followed by an optional range or dimension with a secondary non-number string, an optional step,
|
|
98
|
+
# and optional units, and finally an optional trailing text.
|
|
99
|
+
SEQUENTIAL_TEXT_PATTERN = %r{
|
|
100
|
+
^\s*(?<primary_text>#{BASIC_TEXT}) # 1. Primary non-number string
|
|
101
|
+
\s*(?<primary_step>#{SINGLE_NUMBER})? # 2. Optional step
|
|
102
|
+
\s*(?<primary_unit>#{UNITS_OF_MEASURE})? # 3. Optional units for primary number
|
|
103
|
+
(?: # Optional range or dimension
|
|
104
|
+
\s*(?<seperator>#{SEPERATOR}) # 4. Separator -- capturing allows us to group ranges and dimensions
|
|
105
|
+
\s*(?<secondary_text>#{BASIC_TEXT})? # 5. Optional secondary non-number string
|
|
106
|
+
\s*(?<secondary_step>#{SINGLE_NUMBER}) # 6. Secondary step
|
|
107
|
+
\s*(?<secondary_unit>#{UNITS_OF_MEASURE})? # 7. Optional units for secondary number
|
|
108
|
+
)?
|
|
109
|
+
\s*(?<trailing_text>.*)?$ # 8. Optional trailing text
|
|
110
|
+
}x
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
module IdentifierFormatter
|
|
5
|
+
class << self
|
|
6
|
+
def format_friendly_id(text)
|
|
7
|
+
I18n.transliterate(text)
|
|
8
|
+
.downcase
|
|
9
|
+
.gsub(%r{[^a-z0-9\s\-_/\.\+#]}, "")
|
|
10
|
+
.gsub(/[\s\-\.]+/, "_")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format_handle(text)
|
|
14
|
+
I18n.transliterate(text)
|
|
15
|
+
.downcase
|
|
16
|
+
.gsub(%r{[^a-z0-9\s\-_/\+#]}, "")
|
|
17
|
+
.gsub("+", "-plus-")
|
|
18
|
+
.gsub("#", "-hashtag-")
|
|
19
|
+
.gsub("/", "-")
|
|
20
|
+
.gsub(/[\s\.]+/, "-")
|
|
21
|
+
.gsub("_-_", "-")
|
|
22
|
+
.gsub(/(?<!_)_(?!_)/, "-")
|
|
23
|
+
.gsub(/--+/, "-")
|
|
24
|
+
.gsub(/\A-+|-+\z/, "")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module ProductTaxonomy
|
|
6
|
+
class Loader
|
|
7
|
+
class << self
|
|
8
|
+
def load(values_path:, attributes_path:, categories_glob:)
|
|
9
|
+
return if ProductTaxonomy::Category.all.any?
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
ProductTaxonomy::Value.load_from_source(YAML.load_file(values_path))
|
|
13
|
+
ProductTaxonomy::Attribute.load_from_source(YAML.load_file(attributes_path))
|
|
14
|
+
|
|
15
|
+
categories_source_data = categories_glob.each_with_object([]) do |file, array|
|
|
16
|
+
array.concat(YAML.safe_load_file(file))
|
|
17
|
+
end
|
|
18
|
+
ProductTaxonomy::Category.load_from_source(categories_source_data)
|
|
19
|
+
rescue Errno::ENOENT => e
|
|
20
|
+
raise ArgumentError, "File not found: #{e.message}"
|
|
21
|
+
rescue Psych::SyntaxError => e
|
|
22
|
+
raise ArgumentError, "Invalid YAML: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run validations that can only be run after the taxonomy has been loaded.
|
|
26
|
+
ProductTaxonomy::Value.all.each { |model| model.validate!(:taxonomy_loaded) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
module LocalizationsValidator
|
|
5
|
+
class << self
|
|
6
|
+
# Validate that all localizations are present for the given locales. If no locales are provided, all locales
|
|
7
|
+
# will be validated and the consistency of locales across models will be checked.
|
|
8
|
+
#
|
|
9
|
+
# @param locales [Array<String>, nil] The locales to validate. If nil, all locales will be validated.
|
|
10
|
+
def validate!(locales = nil)
|
|
11
|
+
Category.validate_localizations!(locales)
|
|
12
|
+
Attribute.validate_localizations!(locales)
|
|
13
|
+
Value.validate_localizations!(locales)
|
|
14
|
+
|
|
15
|
+
validate_locales_are_consistent! if locales.nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_locales_are_consistent!
|
|
21
|
+
categories_locales = Category.localizations.keys
|
|
22
|
+
attributes_locales = Attribute.localizations.keys
|
|
23
|
+
values_locales = Value.localizations.keys
|
|
24
|
+
|
|
25
|
+
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
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
class Attribute
|
|
5
|
+
include ActiveModel::Validations
|
|
6
|
+
include FormattedValidationErrors
|
|
7
|
+
extend Localized
|
|
8
|
+
extend Indexed
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Load attributes from source data. By default, this data is deserialized from a YAML file in the `data` directory.
|
|
12
|
+
#
|
|
13
|
+
# @param source_data [Array<Hash>] The source data to load attributes from.
|
|
14
|
+
def load_from_source(source_data)
|
|
15
|
+
raise ArgumentError, "source_data must be a hash" unless source_data.is_a?(Hash)
|
|
16
|
+
raise ArgumentError, "source_data must contain keys \"base_attributes\" and \"extended_attributes\"" unless
|
|
17
|
+
source_data.keys.sort == ["base_attributes", "extended_attributes"]
|
|
18
|
+
|
|
19
|
+
source_data.each do |type, attributes|
|
|
20
|
+
attributes.each do |attribute_data|
|
|
21
|
+
raise ArgumentError, "source_data must contain hashes" unless attribute_data.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
attribute = case type
|
|
24
|
+
when "base_attributes" then attribute_from(attribute_data)
|
|
25
|
+
when "extended_attributes" then extended_attribute_from(attribute_data)
|
|
26
|
+
end
|
|
27
|
+
Attribute.add(attribute)
|
|
28
|
+
attribute.validate!(:create)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Reset all class-level state
|
|
34
|
+
def reset
|
|
35
|
+
@localizations = nil
|
|
36
|
+
@hashed_models = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get base attributes only, sorted by name.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Attribute>] The sorted base attributes.
|
|
42
|
+
def sorted_base_attributes
|
|
43
|
+
all.reject(&:extended?).sort_by(&:name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get the next ID for a newly created attribute.
|
|
47
|
+
#
|
|
48
|
+
# @return [Integer] The next ID.
|
|
49
|
+
def next_id = (all.max_by(&:id)&.id || 0) + 1
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def attribute_from(attribute_data)
|
|
54
|
+
values_by_friendly_id = attribute_data["values"]&.map { Value.find_by(friendly_id: _1) || _1 }
|
|
55
|
+
Attribute.new(
|
|
56
|
+
id: attribute_data["id"],
|
|
57
|
+
name: attribute_data["name"],
|
|
58
|
+
description: attribute_data["description"],
|
|
59
|
+
friendly_id: attribute_data["friendly_id"],
|
|
60
|
+
handle: attribute_data["handle"],
|
|
61
|
+
values: values_by_friendly_id,
|
|
62
|
+
is_manually_sorted: attribute_data["sorting"] == "custom",
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extended_attribute_from(attribute_data)
|
|
67
|
+
value_friendly_id = attribute_data["values_from"]
|
|
68
|
+
ExtendedAttribute.new(
|
|
69
|
+
name: attribute_data["name"],
|
|
70
|
+
handle: attribute_data["handle"],
|
|
71
|
+
description: attribute_data["description"],
|
|
72
|
+
friendly_id: attribute_data["friendly_id"],
|
|
73
|
+
values_from: Attribute.find_by(friendly_id: value_friendly_id) || value_friendly_id,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
validates :id, presence: true, numericality: { only_integer: true }, if: -> { self.class == Attribute }, on: :create
|
|
79
|
+
validates :name, presence: true, on: :create
|
|
80
|
+
validates :friendly_id, presence: true, on: :create
|
|
81
|
+
validates :handle, presence: true, on: :create
|
|
82
|
+
validates :description, presence: true, on: :create
|
|
83
|
+
validates :values, presence: true, if: -> { self.class == Attribute }, on: :create
|
|
84
|
+
validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:friendly_id], on: :create
|
|
85
|
+
validate :values_valid?, on: :create
|
|
86
|
+
|
|
87
|
+
localized_attr_reader :name, :description
|
|
88
|
+
|
|
89
|
+
attr_reader :id, :friendly_id, :handle, :values, :extended_attributes
|
|
90
|
+
|
|
91
|
+
# @param id [Integer] The ID of the attribute.
|
|
92
|
+
# @param name [String] The name of the attribute.
|
|
93
|
+
# @param description [String] The description of the attribute.
|
|
94
|
+
# @param friendly_id [String] The friendly ID of the attribute.
|
|
95
|
+
# @param handle [String] The handle of the attribute.
|
|
96
|
+
# @param values [Array<Value, String>] An array of resolved {Value} objects. When resolving fails, use the friendly
|
|
97
|
+
# ID instead.
|
|
98
|
+
def initialize(id:, name:, description:, friendly_id:, handle:, values:, is_manually_sorted: false)
|
|
99
|
+
@id = id
|
|
100
|
+
@name = name
|
|
101
|
+
@description = description
|
|
102
|
+
@friendly_id = friendly_id
|
|
103
|
+
@handle = handle
|
|
104
|
+
@values = values
|
|
105
|
+
@extended_attributes = []
|
|
106
|
+
@is_manually_sorted = is_manually_sorted
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add an extended attribute to the attribute.
|
|
110
|
+
#
|
|
111
|
+
# @param extended_attribute [ExtendedAttribute] The extended attribute to add.
|
|
112
|
+
def add_extended_attribute(extended_attribute)
|
|
113
|
+
@extended_attributes << extended_attribute
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Add a value to the attribute.
|
|
117
|
+
#
|
|
118
|
+
# @param value [Value] The value to add.
|
|
119
|
+
def add_value(value)
|
|
120
|
+
@values << value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The global ID of the attribute
|
|
124
|
+
#
|
|
125
|
+
# @return [String]
|
|
126
|
+
def gid
|
|
127
|
+
"gid://shopify/TaxonomyAttribute/#{id}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether the attribute is an extended attribute.
|
|
131
|
+
#
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def extended?
|
|
134
|
+
is_a?(ExtendedAttribute)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether the attribute is manually sorted.
|
|
138
|
+
#
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def manually_sorted?
|
|
141
|
+
@is_manually_sorted
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Get the sorted values of the attribute.
|
|
145
|
+
#
|
|
146
|
+
# @param locale [String] The locale to sort by.
|
|
147
|
+
# @return [Array<Value>] The sorted values.
|
|
148
|
+
def sorted_values(locale: "en")
|
|
149
|
+
if manually_sorted?
|
|
150
|
+
values
|
|
151
|
+
else
|
|
152
|
+
Value.sort_by_localized_name(values, locale:)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def values_valid?
|
|
159
|
+
values&.each do |value|
|
|
160
|
+
next if value.is_a?(Value)
|
|
161
|
+
|
|
162
|
+
errors.add(
|
|
163
|
+
:values,
|
|
164
|
+
:not_found,
|
|
165
|
+
message: "not found for friendly ID \"#{value}\"",
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
class Category
|
|
5
|
+
include ActiveModel::Validations
|
|
6
|
+
include FormattedValidationErrors
|
|
7
|
+
extend Localized
|
|
8
|
+
extend Indexed
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :verticals
|
|
12
|
+
|
|
13
|
+
# Load categories from source data.
|
|
14
|
+
#
|
|
15
|
+
# @param source_data [Array<Hash>] The source data to load categories from.
|
|
16
|
+
def load_from_source(source_data)
|
|
17
|
+
raise ArgumentError, "source_data must be an array" unless source_data.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
# First pass: Create all nodes and add to index
|
|
20
|
+
source_data.each do |item|
|
|
21
|
+
Category.create_validate_and_add!(
|
|
22
|
+
id: item["id"],
|
|
23
|
+
name: item["name"],
|
|
24
|
+
attributes: Array(item["attributes"]).map { Attribute.find_by(friendly_id: _1) || _1 },
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Second pass: Build relationships
|
|
29
|
+
source_data.each do |item|
|
|
30
|
+
parent = Category.find_by(id: item["id"])
|
|
31
|
+
add_children(type: "children", item:, parent:)
|
|
32
|
+
add_children(type: "secondary_children", item:, parent:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Third pass: Validate all nodes, sort contents, and collect root nodes for verticals
|
|
36
|
+
@verticals = Category.all.each_with_object([]) do |node, root_nodes|
|
|
37
|
+
node.validate!(:category_tree_loaded)
|
|
38
|
+
node.children.sort_by!(&:name)
|
|
39
|
+
node.attributes.sort_by!(&:name)
|
|
40
|
+
root_nodes << node if node.root?
|
|
41
|
+
end
|
|
42
|
+
@verticals.sort_by!(&:name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Reset all class-level state
|
|
46
|
+
def reset
|
|
47
|
+
@localizations = nil
|
|
48
|
+
@hashed_models = nil
|
|
49
|
+
@verticals = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get all categories in depth-first order.
|
|
53
|
+
#
|
|
54
|
+
# @return [Array<Category>] The categories in depth-first order.
|
|
55
|
+
def all_depth_first
|
|
56
|
+
verticals.flat_map(&:descendants_and_self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def add_children(type:, item:, parent:)
|
|
62
|
+
item[type]&.each do |child_id|
|
|
63
|
+
child = Category.find_by(id: child_id) || child_id
|
|
64
|
+
|
|
65
|
+
case type
|
|
66
|
+
when "children" then parent.add_child(child)
|
|
67
|
+
when "secondary_children" then parent.add_secondary_child(child)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Validations that can be performed as soon as the category is created.
|
|
74
|
+
validates :id, format: { with: /\A[a-z]{2}(-\d+)*\z/ }, on: :create
|
|
75
|
+
validates :name, presence: true, on: :create
|
|
76
|
+
validate :attributes_found?, on: :create
|
|
77
|
+
validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:id], on: :create
|
|
78
|
+
|
|
79
|
+
# Validations that can only be performed after the category tree has been loaded.
|
|
80
|
+
validate :id_matches_depth, on: :category_tree_loaded
|
|
81
|
+
validate :id_starts_with_parent_id, unless: :root?, on: :category_tree_loaded
|
|
82
|
+
validate :children_found?, on: :category_tree_loaded
|
|
83
|
+
validate :secondary_children_found?, on: :category_tree_loaded
|
|
84
|
+
|
|
85
|
+
localized_attr_reader :name, keyed_by: :id
|
|
86
|
+
|
|
87
|
+
attr_reader :id, :children, :secondary_children, :attributes
|
|
88
|
+
attr_accessor :parent, :secondary_parents
|
|
89
|
+
|
|
90
|
+
# @param id [String] The ID of the category.
|
|
91
|
+
# @param name [String] The name of the category.
|
|
92
|
+
# @param attributes [Array<Attribute>] The attributes of the category.
|
|
93
|
+
# @param parent [Category] The parent category of the category.
|
|
94
|
+
def initialize(id:, name:, attributes: [], parent: nil)
|
|
95
|
+
@id = id
|
|
96
|
+
@name = name
|
|
97
|
+
@children = []
|
|
98
|
+
@secondary_children = []
|
|
99
|
+
@attributes = attributes
|
|
100
|
+
@parent = parent
|
|
101
|
+
@secondary_parents = []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#
|
|
105
|
+
# Manipulation
|
|
106
|
+
#
|
|
107
|
+
|
|
108
|
+
# Add a child to the category
|
|
109
|
+
#
|
|
110
|
+
# @param [Category|String] child node, or the friendly ID if the node was not found.
|
|
111
|
+
def add_child(child)
|
|
112
|
+
@children << child
|
|
113
|
+
|
|
114
|
+
return unless child.is_a?(Category)
|
|
115
|
+
|
|
116
|
+
child.parent = self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Add a secondary child to the category
|
|
120
|
+
#
|
|
121
|
+
# @param [Category|String] child node, or the friendly ID if the node was not found.
|
|
122
|
+
def add_secondary_child(child)
|
|
123
|
+
@secondary_children << child
|
|
124
|
+
|
|
125
|
+
return unless child.is_a?(Category)
|
|
126
|
+
|
|
127
|
+
child.secondary_parents << self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Add an attribute to the category
|
|
131
|
+
#
|
|
132
|
+
# @param [Attribute] attribute
|
|
133
|
+
def add_attribute(attribute)
|
|
134
|
+
@attributes << attribute
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
#
|
|
138
|
+
# Information
|
|
139
|
+
#
|
|
140
|
+
def inspect
|
|
141
|
+
"#<#{self.class.name} id=#{id} name=#{name}>"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether the category is the root category
|
|
145
|
+
#
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
def root?
|
|
148
|
+
parent.nil?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Whether the category is a leaf category
|
|
152
|
+
#
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def leaf?
|
|
155
|
+
children.empty?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The level of the category
|
|
159
|
+
#
|
|
160
|
+
# @return [Integer]
|
|
161
|
+
def level
|
|
162
|
+
ancestors.size
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The root category in this category's tree
|
|
166
|
+
#
|
|
167
|
+
# @return [Category]
|
|
168
|
+
def root
|
|
169
|
+
ancestors.last || self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The ancestors of the category
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<Category>]
|
|
175
|
+
def ancestors
|
|
176
|
+
return [] if root?
|
|
177
|
+
|
|
178
|
+
[parent] + parent.ancestors
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The full name of the category
|
|
182
|
+
#
|
|
183
|
+
# @return [String]
|
|
184
|
+
def full_name(locale: "en")
|
|
185
|
+
return name(locale:) if root?
|
|
186
|
+
|
|
187
|
+
parent.full_name(locale:) + " > " + name(locale:)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The global ID of the category
|
|
191
|
+
#
|
|
192
|
+
# @return [String]
|
|
193
|
+
def gid
|
|
194
|
+
"gid://shopify/TaxonomyCategory/#{id}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Split an ID into its parts.
|
|
198
|
+
#
|
|
199
|
+
# @return [Array<String, Integer>] The parts of the ID.
|
|
200
|
+
def id_parts
|
|
201
|
+
parts = id.split("-")
|
|
202
|
+
[parts.first] + parts[1..].map(&:to_i)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Whether the category is a descendant of another category
|
|
206
|
+
#
|
|
207
|
+
# @param [Category] category
|
|
208
|
+
# @return [Boolean]
|
|
209
|
+
def descendant_of?(category)
|
|
210
|
+
ancestors.include?(category)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Iterate over the category and all its descendants
|
|
214
|
+
#
|
|
215
|
+
# @yield [Category]
|
|
216
|
+
def traverse(&block)
|
|
217
|
+
yield self
|
|
218
|
+
children.each { _1.traverse(&block) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The descendants of the category
|
|
222
|
+
def descendants
|
|
223
|
+
children.flat_map { |child| [child] + child.descendants }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The descendants of the category and the category itself
|
|
227
|
+
#
|
|
228
|
+
# @return [Array<Category>]
|
|
229
|
+
def descendants_and_self
|
|
230
|
+
[self] + descendants
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The friendly name of the category
|
|
234
|
+
#
|
|
235
|
+
# @return [String]
|
|
236
|
+
def friendly_name
|
|
237
|
+
"#{id}_#{IdentifierFormatter.format_friendly_id(name)}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The next child ID for the category
|
|
241
|
+
#
|
|
242
|
+
# @return [String]
|
|
243
|
+
def next_child_id
|
|
244
|
+
largest_child_id = children.map { _1.id.split("-").last.to_i }.max || 0
|
|
245
|
+
|
|
246
|
+
"#{id}-#{largest_child_id + 1}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
#
|
|
252
|
+
# Validation
|
|
253
|
+
#
|
|
254
|
+
def id_matches_depth
|
|
255
|
+
parts_count = id.split("-").size
|
|
256
|
+
|
|
257
|
+
return if parts_count == level + 1
|
|
258
|
+
|
|
259
|
+
if level.zero?
|
|
260
|
+
# In this case, the most likely mistake was not adding the category to the parent's `children` field.
|
|
261
|
+
errors.add(:base, :orphan, message: "\"#{id}\" does not appear in the children of any category")
|
|
262
|
+
else
|
|
263
|
+
errors.add(
|
|
264
|
+
:id,
|
|
265
|
+
:depth_mismatch,
|
|
266
|
+
message: "\"#{id}\" has #{parts_count} #{"part".pluralize(parts_count)} but is at level #{level + 1}",
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def id_starts_with_parent_id
|
|
272
|
+
return if id.start_with?(parent.id)
|
|
273
|
+
|
|
274
|
+
errors.add(:id, :prefix_mismatch, message: "\"#{id}\" must be prefixed by \"#{parent.id}\"")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def attributes_found?
|
|
278
|
+
attributes&.each do |attribute|
|
|
279
|
+
next if attribute.is_a?(Attribute)
|
|
280
|
+
|
|
281
|
+
errors.add(
|
|
282
|
+
:attributes,
|
|
283
|
+
:not_found,
|
|
284
|
+
message: "not found for friendly ID \"#{attribute}\"",
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def children_found?
|
|
290
|
+
children&.each do |child|
|
|
291
|
+
next if child.is_a?(Category)
|
|
292
|
+
|
|
293
|
+
errors.add(
|
|
294
|
+
:children,
|
|
295
|
+
:not_found,
|
|
296
|
+
message: "not found for friendly ID \"#{child}\"",
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def secondary_children_found?
|
|
302
|
+
secondary_children&.each do |child|
|
|
303
|
+
next if child.is_a?(Category)
|
|
304
|
+
|
|
305
|
+
errors.add(
|
|
306
|
+
:secondary_children,
|
|
307
|
+
:not_found,
|
|
308
|
+
message: "not found for friendly ID \"#{child}\"",
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|