Skip to content

Commit bc9c03e

Browse files
authored
Migrate add_attribute command to new tool (#581)
### TL;DR Migrated command to add new base and extended attributes to the taxonomy from deprecated tool version to new one. ### What changed? - Migrated `AddAttributeCommand` to create new attributes with values, with support for creating both base attributes (with new values) and extended attributes (inheriting values from base attributes) - Implemented `next_id` method in `Attribute` model to auto-generate IDs for new attributes ### How to test? Add a new base attribute with values: ``` bin/product_taxonomy add_attribute "Alternative color" "Another type of color" --values "Blue,Yellow,Green" ``` Add an extended attribute by extending the base attribute: ``` bin/product_taxonomy add_attribute "Extended color" "An extended type of color" --base_attribute_friendly_id "alternative_color" ``` Sample run: ``` ❯ bin/product_taxonomy add_attribute "Alternative color" "Another type of color" --values "Blue,Yellow,Green" Created base attribute `Alternative color` with friendly_id=`alternative_color` Dumping attributes... Updated `/Users/danielgross/product-taxonomy/data/attributes.yml` Dumping values... Updated `/Users/danielgross/product-taxonomy/data/values.yml` Syncing EN localizations Syncing attributes... Wrote attributes localizations to /Users/danielgross/product-taxonomy/data/localizations/attributes/en.yml Syncing values... Wrote values localizations to /Users/danielgross/product-taxonomy/data/localizations/values/en.yml Version: unstable Generating sibling groups... Generating category search index... Generating attributes... Generating mappings... Generating attributes with categories... Generating attribute with categories search index... Completed in 2.96 seconds ```
2 parents 5a00cbe + d9a1ac1 commit bc9c03e

File tree

6 files changed

+336
-0
lines changed

6 files changed

+336
-0
lines changed

dev/lib/product_taxonomy.rb

+1
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ def data_path = DATA_PATH
5353
require_relative "product_taxonomy/commands/dump_values_command"
5454
require_relative "product_taxonomy/commands/sync_en_localizations_command"
5555
require_relative "product_taxonomy/commands/add_category_command"
56+
require_relative "product_taxonomy/commands/add_attribute_command"
5657
require_relative "product_taxonomy/commands/add_value_command"

dev/lib/product_taxonomy/cli.rb

+7
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def add_category(name, parent_id)
6868
AddCategoryCommand.new(options.merge(name:, parent_id:)).run
6969
end
7070

71+
desc "add_attribute NAME DESCRIPTION", "Add a new attribute to the taxonomy with NAME and DESCRIPTION"
72+
option :values, type: :string, desc: "A comma separated list of values to add to the attribute"
73+
option :base_attribute_friendly_id, type: :string, desc: "Create an extended attribute by extending the attribute with this friendly ID"
74+
def add_attribute(name, description)
75+
AddAttributeCommand.new(options.merge(name:, description:)).run
76+
end
77+
7178
desc "add_value NAME ATTRIBUTE_FRIENDLY_ID", "Add a new value with NAME to the primary attribute with ATTRIBUTE_FRIENDLY_ID"
7279
def add_value(name, attribute_friendly_id)
7380
AddValueCommand.new(options.merge(name:, attribute_friendly_id:)).run
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
module ProductTaxonomy
4+
class AddAttributeCommand < Command
5+
def initialize(options)
6+
super
7+
load_taxonomy
8+
@name = options[:name]
9+
@description = options[:description]
10+
@values = options[:values]
11+
@base_attribute_friendly_id = options[:base_attribute_friendly_id]
12+
end
13+
14+
def execute
15+
if @base_attribute_friendly_id
16+
create_extended_attribute!
17+
else
18+
create_base_attribute_with_values!
19+
end
20+
update_data_files!
21+
end
22+
23+
private
24+
25+
def create_base_attribute_with_values!
26+
raise "Values must be provided when creating a base attribute" if value_names.empty?
27+
28+
@attribute = Attribute.create_validate_and_add!(
29+
id: Attribute.next_id,
30+
name: @name,
31+
description: @description,
32+
friendly_id:,
33+
handle:,
34+
values: find_or_create_values,
35+
)
36+
logger.info("Created base attribute `#{@attribute.name}` with friendly_id=`#{@attribute.friendly_id}`")
37+
end
38+
39+
def create_extended_attribute!
40+
raise "Values should not be provided when creating an extended attribute" if value_names.any?
41+
42+
@attribute = ExtendedAttribute.create_validate_and_add!(
43+
name: @name,
44+
description: @description,
45+
friendly_id:,
46+
handle:,
47+
values_from: Attribute.find_by(friendly_id: @base_attribute_friendly_id) || @base_attribute_friendly_id,
48+
)
49+
logger.info("Created extended attribute `#{@attribute.name}` with friendly_id=`#{@attribute.friendly_id}`")
50+
end
51+
52+
def update_data_files!
53+
DumpAttributesCommand.new({}).execute
54+
DumpValuesCommand.new({}).execute
55+
SyncEnLocalizationsCommand.new(targets: "attributes,values").execute
56+
GenerateDocsCommand.new({}).execute
57+
end
58+
59+
def friendly_id
60+
@friendly_id ||= IdentifierFormatter.format_friendly_id(@name)
61+
end
62+
63+
def handle
64+
@handle ||= IdentifierFormatter.format_handle(friendly_id)
65+
end
66+
67+
def value_names
68+
@values&.split(",")&.map(&:strip) || []
69+
end
70+
71+
def find_or_create_values
72+
value_names.map do |value_name|
73+
value_friendly_id = IdentifierFormatter.format_friendly_id("#{friendly_id}__#{value_name}")
74+
existing_value = Value.find_by(friendly_id: value_friendly_id)
75+
next existing_value if existing_value
76+
77+
Value.create_validate_and_add!(
78+
id: Value.next_id,
79+
name: value_name,
80+
friendly_id: value_friendly_id,
81+
handle: IdentifierFormatter.format_handle(value_friendly_id),
82+
)
83+
end
84+
end
85+
end
86+
end

dev/lib/product_taxonomy/models/attribute.rb

+5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def sorted_base_attributes
4242
all.reject(&:extended?).sort_by(&:name)
4343
end
4444

45+
# Get the next ID for a newly created attribute.
46+
#
47+
# @return [Integer] The next ID.
48+
def next_id = (all.max_by(&:id)&.id || 0) + 1
49+
4550
private
4651

4752
def attribute_from(attribute_data)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module ProductTaxonomy
6+
class AddAttributeCommandTest < TestCase
7+
setup do
8+
@base_attribute = Attribute.new(
9+
id: 1,
10+
name: "Color",
11+
description: "Defines the primary color or pattern",
12+
friendly_id: "color",
13+
handle: "color",
14+
values: [
15+
Value.new(
16+
id: 1,
17+
name: "Black",
18+
friendly_id: "color__black",
19+
handle: "color__black",
20+
),
21+
],
22+
)
23+
24+
Attribute.add(@base_attribute)
25+
Value.add(@base_attribute.values.first)
26+
27+
AddAttributeCommand.any_instance.stubs(:load_taxonomy)
28+
DumpAttributesCommand.any_instance.stubs(:load_taxonomy)
29+
DumpValuesCommand.any_instance.stubs(:load_taxonomy)
30+
SyncEnLocalizationsCommand.any_instance.stubs(:load_taxonomy)
31+
GenerateDocsCommand.any_instance.stubs(:load_taxonomy)
32+
end
33+
34+
test "execute successfully adds a new base attribute with values" do
35+
DumpAttributesCommand.any_instance.expects(:execute).once
36+
DumpValuesCommand.any_instance.expects(:execute).once
37+
SyncEnLocalizationsCommand.any_instance.expects(:execute).once
38+
GenerateDocsCommand.any_instance.expects(:execute).once
39+
40+
AddAttributeCommand.new(
41+
name: "Size",
42+
description: "Product size information",
43+
values: "Small, Medium, Large",
44+
).execute
45+
46+
new_attribute = Attribute.find_by(friendly_id: "size")
47+
assert_not_nil new_attribute
48+
assert_equal 2, new_attribute.id # Since id 1 already exists
49+
assert_equal "Size", new_attribute.name
50+
assert_equal "Product size information", new_attribute.description
51+
assert_equal "size", new_attribute.friendly_id
52+
assert_equal "size", new_attribute.handle
53+
54+
assert_equal 3, new_attribute.values.size
55+
value_names = new_attribute.values.map(&:name)
56+
assert_includes value_names, "Small"
57+
assert_includes value_names, "Medium"
58+
assert_includes value_names, "Large"
59+
60+
value_friendly_ids = new_attribute.values.map(&:friendly_id)
61+
assert_includes value_friendly_ids, "size__small"
62+
assert_includes value_friendly_ids, "size__medium"
63+
assert_includes value_friendly_ids, "size__large"
64+
end
65+
66+
test "execute successfully adds an extended attribute" do
67+
DumpAttributesCommand.any_instance.expects(:execute).once
68+
DumpValuesCommand.any_instance.expects(:execute).once
69+
SyncEnLocalizationsCommand.any_instance.expects(:execute).once
70+
GenerateDocsCommand.any_instance.expects(:execute).once
71+
72+
AddAttributeCommand.new(
73+
name: "Clothing Color",
74+
description: "Color of clothing items",
75+
base_attribute_friendly_id: "color",
76+
).execute
77+
78+
new_attribute = Attribute.find_by(friendly_id: "clothing_color")
79+
assert_not_nil new_attribute
80+
assert_instance_of ExtendedAttribute, new_attribute
81+
assert_equal "Clothing Color", new_attribute.name
82+
assert_equal "Color of clothing items", new_attribute.description
83+
assert_equal "clothing_color", new_attribute.friendly_id
84+
assert_equal "clothing-color", new_attribute.handle
85+
86+
assert_equal @base_attribute, new_attribute.base_attribute
87+
88+
assert_equal @base_attribute.values.size, new_attribute.values.size
89+
assert_equal @base_attribute.values.first.name, new_attribute.values.first.name
90+
end
91+
92+
test "execute raises error when creating base attribute without values" do
93+
assert_raises(RuntimeError) do
94+
AddAttributeCommand.new(
95+
name: "Material",
96+
description: "Product material information",
97+
values: "",
98+
).execute
99+
end
100+
end
101+
102+
test "execute raises error when creating extended attribute with values" do
103+
assert_raises(RuntimeError) do
104+
AddAttributeCommand.new(
105+
name: "Clothing Color",
106+
description: "Color of clothing items",
107+
base_attribute_friendly_id: "color",
108+
values: "Red, Blue",
109+
).execute
110+
end
111+
end
112+
113+
test "execute raises error when base attribute for extended attribute doesn't exist" do
114+
assert_raises(ActiveModel::ValidationError) do
115+
AddAttributeCommand.new(
116+
name: "Clothing Material",
117+
description: "Material of clothing items",
118+
base_attribute_friendly_id: "nonexistent",
119+
).execute
120+
end
121+
end
122+
123+
test "execute raises error when attribute with same friendly_id already exists" do
124+
assert_raises(ActiveModel::ValidationError) do
125+
AddAttributeCommand.new(
126+
name: "Color", # This will generate the same friendly_id as @base_attribute
127+
description: "Another color attribute",
128+
values: "Red, Blue",
129+
).execute
130+
end
131+
end
132+
133+
test "execute creates values with correct friendly_ids and handles" do
134+
DumpAttributesCommand.any_instance.stubs(:execute)
135+
DumpValuesCommand.any_instance.stubs(:execute)
136+
SyncEnLocalizationsCommand.any_instance.stubs(:execute)
137+
GenerateDocsCommand.any_instance.stubs(:execute)
138+
139+
AddAttributeCommand.new(
140+
name: "Material Type",
141+
description: "Type of material used",
142+
values: "Cotton, Polyester",
143+
).execute
144+
145+
new_attribute = Attribute.find_by(friendly_id: "material_type")
146+
assert_not_nil new_attribute
147+
148+
cotton_value = new_attribute.values.find { |v| v.name == "Cotton" }
149+
assert_not_nil cotton_value
150+
assert_equal "material_type__cotton", cotton_value.friendly_id
151+
assert_equal "material-type__cotton", cotton_value.handle
152+
153+
polyester_value = new_attribute.values.find { |v| v.name == "Polyester" }
154+
assert_not_nil polyester_value
155+
assert_equal "material_type__polyester", polyester_value.friendly_id
156+
assert_equal "material-type__polyester", polyester_value.handle
157+
end
158+
159+
test "execute reuses existing values with same friendly_id" do
160+
existing_value = Value.new(
161+
id: 2,
162+
name: "Existing Cotton",
163+
friendly_id: "material_type__cotton",
164+
handle: "material_type__cotton",
165+
)
166+
Value.add(existing_value)
167+
168+
DumpAttributesCommand.any_instance.stubs(:execute)
169+
DumpValuesCommand.any_instance.stubs(:execute)
170+
SyncEnLocalizationsCommand.any_instance.stubs(:execute)
171+
GenerateDocsCommand.any_instance.stubs(:execute)
172+
173+
AddAttributeCommand.new(
174+
name: "Material Type",
175+
description: "Type of material used",
176+
values: "Cotton, Polyester",
177+
).execute
178+
179+
new_attribute = Attribute.find_by(friendly_id: "material_type")
180+
assert_not_nil new_attribute
181+
182+
cotton_value = new_attribute.values.find { |v| v.friendly_id == "material_type__cotton" }
183+
assert_not_nil cotton_value
184+
assert_equal existing_value, cotton_value
185+
assert_equal "Existing Cotton", cotton_value.name # Name from existing value
186+
187+
polyester_value = new_attribute.values.find { |v| v.name == "Polyester" }
188+
assert_not_nil polyester_value
189+
assert_equal "material_type__polyester", polyester_value.friendly_id
190+
end
191+
192+
test "execute updates data files after creating attribute" do
193+
DumpAttributesCommand.any_instance.expects(:execute).once
194+
DumpValuesCommand.any_instance.expects(:execute).once
195+
SyncEnLocalizationsCommand.expects(:new).with(targets: "attributes,values").returns(stub(execute: true))
196+
GenerateDocsCommand.any_instance.expects(:execute).once
197+
198+
AddAttributeCommand.new(
199+
name: "Size",
200+
description: "Product size information",
201+
values: "Small, Medium, Large",
202+
).execute
203+
end
204+
end
205+
end

dev/test/models/attribute_test.rb

+32
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,38 @@ class AttributeTest < TestCase
349349
refute @attribute.extended?
350350
end
351351

352+
test "next_id returns 1 when there are no attributes" do
353+
Attribute.reset
354+
assert_equal 1, Attribute.next_id
355+
end
356+
357+
test "next_id returns max id + 1 when there are attributes" do
358+
Attribute.reset
359+
360+
attribute1 = Attribute.new(
361+
id: 5,
362+
name: "Size",
363+
description: "Defines the size of the product",
364+
friendly_id: "size",
365+
handle: "size",
366+
values: [@value],
367+
)
368+
369+
attribute2 = Attribute.new(
370+
id: 10,
371+
name: "Material",
372+
description: "Defines the material of the product",
373+
friendly_id: "material",
374+
handle: "material",
375+
values: [@value],
376+
)
377+
378+
Attribute.add(attribute1)
379+
Attribute.add(attribute2)
380+
381+
assert_equal 11, Attribute.next_id
382+
end
383+
352384
private
353385

354386
def stub_localizations

0 commit comments

Comments
 (0)