shopify_transporter 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +23 -0
  3. data/LICENSE +20 -0
  4. data/README.md +290 -0
  5. data/RELEASING +16 -0
  6. data/Rakefile +11 -0
  7. data/exe/shopify_transporter +47 -0
  8. data/lib/shopify_transporter/generators/base_group.rb +25 -0
  9. data/lib/shopify_transporter/generators/generate.rb +64 -0
  10. data/lib/shopify_transporter/generators/new.rb +23 -0
  11. data/lib/shopify_transporter/generators.rb +3 -0
  12. data/lib/shopify_transporter/pipeline/all_platforms/metafields.rb +61 -0
  13. data/lib/shopify_transporter/pipeline/magento/customer/addresses_attribute.rb +60 -0
  14. data/lib/shopify_transporter/pipeline/magento/customer/top_level_attributes.rb +47 -0
  15. data/lib/shopify_transporter/pipeline/magento/order/addresses_attribute.rb +46 -0
  16. data/lib/shopify_transporter/pipeline/magento/order/line_items.rb +55 -0
  17. data/lib/shopify_transporter/pipeline/magento/order/top_level_attributes.rb +39 -0
  18. data/lib/shopify_transporter/pipeline/magento/product/top_level_attributes.rb +36 -0
  19. data/lib/shopify_transporter/pipeline/stage.rb +16 -0
  20. data/lib/shopify_transporter/pipeline.rb +4 -0
  21. data/lib/shopify_transporter/record_builder.rb +51 -0
  22. data/lib/shopify_transporter/shopify/attributes_accumulator.rb +43 -0
  23. data/lib/shopify_transporter/shopify/attributes_helpers.rb +53 -0
  24. data/lib/shopify_transporter/shopify/customer.rb +79 -0
  25. data/lib/shopify_transporter/shopify/order.rb +107 -0
  26. data/lib/shopify_transporter/shopify/product.rb +118 -0
  27. data/lib/shopify_transporter/shopify/record.rb +60 -0
  28. data/lib/shopify_transporter/shopify.rb +7 -0
  29. data/lib/shopify_transporter/version.rb +4 -0
  30. data/lib/shopify_transporter.rb +247 -0
  31. data/lib/tasks/factory_bot.rake +9 -0
  32. data/lib/templates/custom_stage.tt +31 -0
  33. data/lib/templates/gemfile.tt +5 -0
  34. data/lib/templates/magento/config.tt +23 -0
  35. data/shopify_transporter.gemspec +36 -0
  36. metadata +152 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+ require 'active_support/inflector'
3
+ require 'csv'
4
+ require_relative 'record'
5
+ require_relative 'attributes_helpers'
6
+
7
+ module ShopifyTransporter
8
+ module Shopify
9
+ class Product < Record
10
+ TOP_LEVEL_ATTRIBUTES = %w(
11
+ handle title body_html vendor product_type tags template_suffix published_scope published published_at
12
+ option1_name option1_value option2_name option2_value option3_name option3_value variant_sku
13
+ metafields_global_title_tag metafields_global_description_tag
14
+ ).freeze
15
+
16
+ VARIANT_PREFIX = 'variant_'
17
+
18
+ VARIANT_ATTRIBUTES = %w(
19
+ sku grams inventory_tracker inventory_qty inventory_policy
20
+ fulfillment_service price compare_at_price requires_shipping
21
+ taxable barcode weight_unit tax_code
22
+ ).freeze
23
+
24
+ class << self
25
+ include AttributesHelpers
26
+
27
+ def header
28
+ [
29
+ 'Handle', 'Title', 'Body (HTML)', 'Vendor', 'Type', 'Tags', 'Template Suffix', 'Published Scope',
30
+ 'Published', 'Published At', 'Option1 Name', 'Option1 Value', 'Option2 Name', 'Option2 Value',
31
+ 'Option3 Name', 'Option3 Value', 'Variant SKU', 'Metafields Global Title Tag',
32
+ 'Metafields Global Description Tag', 'Metafield Namespace', 'Metafield Key', 'Metafield Value',
33
+ 'Metafield Value Type', 'Variant Grams', 'Variant Inventory Tracker', 'Variant Inventory Qty',
34
+ 'Variant Inventory Policy', 'Variant Fulfillment Service', 'Variant Price', 'Variant Compare At Price',
35
+ 'Variant Requires Shipping', 'Variant Taxable', 'Variant Barcode', 'Image Attachment', 'Image Src',
36
+ 'Image Position', 'Image Alt Text', 'Variant Image', 'Variant Weight Unit', 'Variant Tax Code'
37
+ ].to_csv
38
+ end
39
+
40
+ def columns
41
+ @columns ||= header.parse_csv.map do |header_column|
42
+ header_column = 'product_type' if header_column == 'Type'
43
+ normalize_string(header_column)
44
+ end
45
+ end
46
+
47
+ def keys
48
+ %w(handle).freeze
49
+ end
50
+ end
51
+
52
+ def to_csv
53
+ CSV.generate do |csv|
54
+ csv << top_level_row_values
55
+ metafield_row_values.each { |row| csv << row }
56
+ variant_row_values.each { |row| csv << row }
57
+ variant_metafield_row_values.each { |row| csv << row }
58
+ image_row_values.each { |row| csv << row }
59
+ end
60
+ end
61
+
62
+ def top_level_row_values
63
+ base_hash.merge(record_hash.slice(*TOP_LEVEL_ATTRIBUTES)).tap do |product_hash|
64
+ next if record_hash['options'].blank?
65
+
66
+ product_hash['option1_name'] = record_hash['options'][0]['name']
67
+ product_hash['option2_name'] = record_hash['options'][1]['name']
68
+ product_hash['option3_name'] = record_hash['options'][2]['name']
69
+ end.values
70
+ end
71
+
72
+ def variant_row_values
73
+ return [] if record_hash['variants'].blank?
74
+ record_hash['variants'].map do |variant_hash|
75
+ variant = variant_hash.slice(*VARIANT_ATTRIBUTES)
76
+ variant.transform_keys! { |k| "#{VARIANT_PREFIX}#{k}" }
77
+ variant.merge!(variant_option_hash(variant_hash))
78
+ variant['variant_image'] = variant_hash['variant_image'] && variant_hash['variant_image']['src']
79
+ row_values_from(variant)
80
+ end
81
+ end
82
+
83
+ def variant_metafield_row_values
84
+ return [] if record_hash['variants'].blank?
85
+ record_hash['variants'].flat_map do |variant_hash|
86
+ next if variant_hash['metafields'].blank?
87
+ variant_hash['metafields'].map do |metafield_hash|
88
+ metafield = metafield_hash.slice(*METAFIELD_ATTRIBUTES)
89
+ metafield.transform_keys! { |k| "#{METAFIELD_PREFIX}#{k}" }
90
+ metafield.merge!(variant_option_hash(variant_hash))
91
+ row_values_from(metafield) if self.class.has_values?(metafield)
92
+ end.compact
93
+ end.compact
94
+ end
95
+
96
+ def image_row_values
97
+ return [] if record_hash['images'].blank?
98
+ record_hash['images'].map do |image_hash|
99
+ image = {
100
+ 'image_attachment' => image_hash['attachment'],
101
+ 'image_src' => image_hash['src'],
102
+ 'image_position' => image_hash['position'],
103
+ 'image_alt_text' => image_hash['alt'],
104
+ }
105
+ row_values_from(image) if self.class.has_values?(image)
106
+ end.compact
107
+ end
108
+
109
+ def variant_option_hash(variant_hash)
110
+ {
111
+ 'option1_value' => variant_hash['option1'],
112
+ 'option2_value' => variant_hash['option2'],
113
+ 'option3_value' => variant_hash['option3'],
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyTransporter
3
+ module Shopify
4
+ class Record
5
+ METAFIELD_PREFIX = "metafield_"
6
+
7
+ METAFIELD_ATTRIBUTES = %w(namespace key value value_type).freeze
8
+
9
+ def initialize(hash = {})
10
+ @record_hash = hash
11
+ end
12
+
13
+ def metafield_row_values
14
+ return [] if @record_hash['metafields'].blank?
15
+ @record_hash['metafields'].map do |metafield_hash|
16
+ metafield = metafield_hash.slice(*METAFIELD_ATTRIBUTES)
17
+ metafield.transform_keys! { |k| "#{METAFIELD_PREFIX}#{k}" }
18
+ row_values_from(metafield) if self.class.has_values?(metafield)
19
+ end.compact
20
+ end
21
+
22
+ class << self
23
+ def header
24
+ raise NotImplementedError
25
+ end
26
+ end
27
+
28
+ def to_csv
29
+ raise NotImplementedError
30
+ end
31
+
32
+ private
33
+
34
+ attr_accessor :record_hash
35
+
36
+ class << self
37
+ def columns
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def keys
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def has_values?(hash)
46
+ hash.except(*keys).any? { |_, v| v }
47
+ end
48
+ end
49
+
50
+ def base_hash
51
+ row = self.class.columns.each_with_object({}) { |column_name, hash| hash[column_name] = nil }
52
+ row.merge(record_hash.slice(*self.class.keys))
53
+ end
54
+
55
+ def row_values_from(row_hash)
56
+ base_hash.merge(row_hash).values_at(*self.class.columns)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shopify/attributes_accumulator'
4
+ require_relative 'shopify/customer'
5
+ require_relative 'shopify/product'
6
+ require_relative 'shopify/order'
7
+ require_relative 'shopify/record'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module ShopifyTransporter
3
+ VERSION = "1.0.0"
4
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+ require 'thor'
3
+ require_relative 'shopify_transporter/generators.rb'
4
+ require_relative 'shopify_transporter/pipeline.rb'
5
+ require_relative 'shopify_transporter/shopify.rb'
6
+ require_relative 'shopify_transporter/record_builder.rb'
7
+ Dir["#{Dir.pwd}/lib/custom_pipeline_stages/**/*.rb"].each { |f| require f }
8
+
9
+ module ShopifyTransporter
10
+ DEFAULT_METAFIELD_NAMESPACE = 'migrated_data'
11
+ end
12
+
13
+ require 'csv'
14
+ require 'yaml'
15
+ require 'yajl'
16
+
17
+ class TransporterTool
18
+ class ConversionError < StandardError; end
19
+
20
+ class StageNotFoundError < ConversionError
21
+ def initialize(stage_name)
22
+ super("Unable to find stage named '#{stage_name}'")
23
+ end
24
+ end
25
+
26
+ class InvalidObjectType < ConversionError
27
+ def initialize(object_type)
28
+ super(
29
+ "Unable to find object type named: '#{object_type}' in config.yml." \
30
+ "Are you sure '#{object_type}' is listed in the config.yml?"
31
+ )
32
+ end
33
+ end
34
+
35
+ class UnsupportedStageTypeError < ConversionError
36
+ def initialize(name, type)
37
+ super(
38
+ "Stage: '#{name}' has an unsupported type: '#{type}'. It must be one of 'default', 'custom' or 'all_platforms'"
39
+ )
40
+ end
41
+ end
42
+
43
+ def initialize(*files, config, object_type)
44
+ config_file(config)
45
+ input_files(*files)
46
+ @object_type = object_type
47
+ return if @config.nil? || @input_files.nil?
48
+
49
+ raise InvalidObjectType, object_type unless supported_object_type?(object_type)
50
+
51
+ build_classes_based_on_config
52
+ initialize_stages
53
+ end
54
+
55
+ SUPPORTED_FILE_TYPES = %w(.csv .json)
56
+
57
+ def supported_object_type?(object_type)
58
+ @config['object_types'].key?(object_type)
59
+ end
60
+
61
+ def initialize_stages
62
+ @pipeline_stages ||= {}
63
+ @config['object_types'][@object_type]['pipeline_stages'].each do |pipeline_stage|
64
+ initialize_pipeline_stage(pipeline_stage)
65
+ end
66
+ end
67
+
68
+ def run
69
+ return if @input_files.nil?
70
+
71
+ unless file_extension_supported?
72
+ $stderr.puts "File type must be one of: #{SUPPORTED_FILE_TYPES.join(', ')}."
73
+ return
74
+ end
75
+
76
+ @input_files.each do |file_name|
77
+ run_based_on_file_ext(file_name)
78
+ end
79
+
80
+ complete
81
+ end
82
+
83
+ private
84
+
85
+ def file_extension_supported?
86
+ @input_files.all? do |file_name|
87
+ ext = get_ext(file_name)
88
+ SUPPORTED_FILE_TYPES.include?(ext)
89
+ end
90
+ end
91
+
92
+ def get_ext(file_name)
93
+ File.extname(file_name).downcase
94
+ end
95
+
96
+ def run_based_on_file_ext(file_name)
97
+ ext = get_ext(file_name)
98
+ ext == '.csv' ? run_csv(file_name) : run_json(file_name)
99
+ end
100
+
101
+ def run_csv(file_name)
102
+ row_number = 1
103
+ CSV.foreach(file_name, headers: true).each do |row|
104
+ row_number += 1
105
+ process(row.to_hash, file_name, row_number)
106
+ end
107
+ end
108
+
109
+ def run_json(file_name)
110
+ file_data = File.read(file_name)
111
+ parsed_file_data = Yajl::Parser.parse(file_data)
112
+ return if parsed_file_data.nil? || parsed_file_data.empty?
113
+
114
+ record = 1
115
+ parsed_file_data.each do |json_row|
116
+ process(json_row, file_name, record)
117
+ record += 1
118
+ end
119
+ end
120
+
121
+ def initialize_pipeline_stage(pipeline_stage)
122
+ name = stage_name(pipeline_stage)
123
+ params = stage_params(pipeline_stage)
124
+ type = stage_type(pipeline_stage)
125
+ @pipeline_stages[name] = stage_class_from(name, type).new(params)
126
+ end
127
+
128
+ def stage_name(pipeline_stage)
129
+ pipeline_stage.class == String ? pipeline_stage : pipeline_stage.keys.first
130
+ end
131
+
132
+ def class_exists?(pipeline_stage_class)
133
+ pipeline_stage_class && pipeline_stage_class < ShopifyTransporter::Pipeline::Stage
134
+ end
135
+
136
+ def stage_params(pipeline_stage)
137
+ pipeline_stage['params'] if pipeline_stage.class == Hash
138
+ end
139
+
140
+ def stage_type(pipeline_stage)
141
+ return 'default' if pipeline_stage.class == String || pipeline_stage['type'].nil?
142
+ pipeline_stage['type']
143
+ end
144
+
145
+ def custom_stage_class_from(stage_name)
146
+ "CustomPipeline::#{@object_type.capitalize}::#{stage_name}"
147
+ end
148
+
149
+ def stage_class_from(name, type)
150
+ class_name = stage_classname(name, type)
151
+ klass = begin
152
+ k = Object.const_get(class_name)
153
+ k.is_a?(Class) && k
154
+ rescue NameError
155
+ nil
156
+ end
157
+ raise StageNotFoundError, name unless class_exists?(klass)
158
+ klass
159
+ end
160
+
161
+ def stage_classname(name, type)
162
+ case type
163
+ when 'default'
164
+ "ShopifyTransporter::Pipeline::#{@config['platform_type']}::#{@object_type.capitalize}::#{name}"
165
+ when 'all_platforms'
166
+ "ShopifyTransporter::Pipeline::AllPlatforms::#{name}"
167
+ when 'custom'
168
+ "CustomPipeline::#{@object_type.capitalize}::#{name}"
169
+ else
170
+ raise UnsupportedStageTypeError.new(name, type)
171
+ end
172
+ end
173
+
174
+ def process(input, file_name, row_number)
175
+ @record_builder.build(input) do |record|
176
+ run_pipeline(input, record)
177
+ end
178
+ rescue ShopifyTransporter::RequiredKeyMissing, ShopifyTransporter::MissingParentObject => e
179
+ $stderr.puts error_message_from(e, file_name, row_number)
180
+ end
181
+
182
+ def run_pipeline(row, record)
183
+ @pipeline_stages.each do |_stage_name, stage|
184
+ stage.convert(row, record)
185
+ end
186
+ end
187
+
188
+ def error_message_from(error, file_name, row_number)
189
+ ext = get_ext(file_name)
190
+ if ext == '.csv'
191
+ "error: #{file_name}:#{row_number}, message: #{error.message}"
192
+ else
193
+ "error in file: #{file_name} at record number #{row_number}, message: #{error.message}"
194
+ end
195
+ end
196
+
197
+ def complete
198
+ puts @record_class.header
199
+ @record_builder.instances.each do |_, record_hash|
200
+ puts @record_class.new(record_hash).to_csv
201
+ end
202
+ end
203
+
204
+ def config_file(config)
205
+ @config = YAML.load_file(config) if valid_config_file?(config)
206
+ end
207
+
208
+ def input_files(*files)
209
+ @input_files = *files if valid_files?(*files)
210
+ end
211
+
212
+ def valid_config_file?(config)
213
+ valid_file?(config)
214
+ end
215
+
216
+ def valid_files?(*files)
217
+ return false if files.any? do |f|
218
+ !valid_file?(f)
219
+ end
220
+
221
+ true
222
+ end
223
+
224
+ def valid_file?(path)
225
+ unless File.exist?(path)
226
+ puts "File #{path} can't be found"
227
+ return false
228
+ end
229
+
230
+ true
231
+ end
232
+
233
+ def build_classes_based_on_config
234
+ @record_class = Object.const_get("ShopifyTransporter::Shopify::#{@object_type.capitalize}")
235
+ @record_builder = ShopifyTransporter::RecordBuilder.new(
236
+ record_key_from_config, key_required_from_config
237
+ )
238
+ end
239
+
240
+ def record_key_from_config
241
+ @config['object_types'][@object_type]['record_key']
242
+ end
243
+
244
+ def key_required_from_config
245
+ @config['object_types'][@object_type]['key_required']
246
+ end
247
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ require 'factory_bot'
3
+
4
+ namespace :factory_bot do
5
+ desc "Verify that all FactoryBot factories are valid"
6
+ task :lint do
7
+ FactoryBot.lint
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ require 'shopify_transporter'
3
+
4
+ module CustomPipeline
5
+ module <%= @object_type %>
6
+ class <%= @pipeline_class %> < ShopifyTransporter::Pipeline::Stage
7
+ def convert(input, record)
8
+ # The convert command reads the input files one-by-one, line-by-line.
9
+ #
10
+ # For each row, the value of the record_key column is used to lookup the Shopify object being built.
11
+ #
12
+ # If the Shopify object doesn't exist, it's created as a default empty hash.
13
+ #
14
+ # It's the role of a pipeline stage to examine the input rows and populate attributes on the Shopify object.
15
+ #
16
+ # For example, the TopLevelAttributes stage of a Magento customer migration would look for a column called firstname on the input,
17
+ # and then populate the Shopify object accordingly:
18
+ #
19
+ # record['first_name'] = input['firstname']
20
+ #
21
+ # Any modifications to the record within a pipeline stage are permanent to the Shopify record associated with the record_key.
22
+ #
23
+ # The next pipline stage to receive the record will receive the same input and the existing record which would consist of:
24
+ #
25
+ # {
26
+ # 'first_name' => 'John',
27
+ # }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'shopify_transporter'
@@ -0,0 +1,23 @@
1
+ platform_type: Magento
2
+ object_types:
3
+ customer:
4
+ record_key: email
5
+ pipeline_stages:
6
+ - TopLevelAttributes
7
+ - AddressesAttribute
8
+ - Metafields:
9
+ type: all_platforms
10
+ params:
11
+ # Specify a custom namespace for your metafields with metafield_namespace.
12
+ # Uses migrated_data by default.
13
+ # metafield_namespace: migrated_data
14
+ metafields:
15
+ - website
16
+ - group
17
+ - free_trial_start_at
18
+ order:
19
+ record_key: increment_id
20
+ pipeline_stages:
21
+ - TopLevelAttributes
22
+ - LineItems
23
+ - AddressesAttribute
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ $:.push File.expand_path('../lib', __FILE__)
3
+ require 'shopify_transporter/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.required_ruby_version = '>= 2.4.0'
7
+
8
+ spec.name = %q{shopify_transporter}
9
+ spec.version = ShopifyTransporter::VERSION
10
+ spec.author = 'Shopify'
11
+ spec.email = %q{developers@shopify.com}
12
+
13
+ spec.summary = 'Tools for migrating to Shopify'
14
+ spec.description = 'The Transporter tool allows you to convert data from a third-party platform into a format that can be imported into Shopify.'
15
+ spec.homepage = %q{https://help.shopify.com/manual/migrating-to-shopify}
16
+ spec.license = 'Shopify'
17
+ spec.extra_rdoc_files = [
18
+ 'LICENSE',
19
+ 'README.md',
20
+ 'RELEASING'
21
+ ]
22
+
23
+ whitelisted_files = "exe lib Gemfile LICENSE Rakefile README.md shopify_transporter.gemspec"
24
+
25
+ spec.files = `git ls-files -z #{whitelisted_files}`.split("\x0")
26
+ spec.bindir = 'exe'
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ['lib']
29
+
30
+ spec.add_development_dependency 'bundler', '~> 1'
31
+ spec.add_development_dependency 'rake', '~> 12.3'
32
+
33
+ spec.add_dependency 'activesupport', '~> 5.1'
34
+ spec.add_dependency 'thor', '~> 0.20'
35
+ spec.add_dependency 'yajl-ruby', '~> 1.3'
36
+ end