shopify_transporter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/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