importer 0.3.2 → 0.4.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.
@@ -1,5 +1,43 @@
1
1
  module Importer
2
- module Import
2
+ # Summary of import.
3
+ # Contains detailed information about import process, all imported objects,
4
+ # no matter if they were imported successfully or not.
5
+ #
6
+ # The importer builds it's Import instance by adding imported objects via
7
+ # +add_object+.
8
+ #
9
+ # If you want to have your own implementation of Import summary
10
+ # (f.e. activerecord-based), you can force the importer to use it with:
11
+ #
12
+ # Product.import(path_to_xml_or_csv_file, :import => CustomImportClass)
13
+ #
14
+ # Just be sure to implement +add_object+ and +build_imported_object+
15
+ # methods in your custom class.
16
+ class Import
17
+ attr_reader :imported_objects
3
18
 
19
+ def initialize
20
+ @imported_objects = []
21
+ end
22
+
23
+ def add_object(imported_object)
24
+ imported_objects << imported_object
25
+ end
26
+
27
+ def build_imported_object
28
+ ImportedObject.new
29
+ end
30
+
31
+ def new_imported_objects
32
+ imported_objects.select { |object| object.state == 'new_object' }
33
+ end
34
+
35
+ def existing_imported_objects
36
+ imported_objects.select { |object| object.state == 'existing_object' }
37
+ end
38
+
39
+ def invalid_imported_objects
40
+ imported_objects.select { |object| object.state == 'invalid_object' }
41
+ end
4
42
  end
5
43
  end
@@ -1,5 +1,32 @@
1
1
  module Importer
2
- module ImportedObject
2
+ # Instances of this class are created during import process and contain
3
+ # detailed information about imported objects.
4
+ #
5
+ # Attributes:
6
+ # * +state+ - an imported object can be in one of three states:
7
+ # * new_object - new object was detected and successfully imported
8
+ # * existing_object - already existing object was detected and successfully
9
+ # imported
10
+ # * invalid_object - detected object could not have been imported because of
11
+ # validation errors
12
+ # * +object+ - pointer to actual object created or updated during import
13
+ # * +data+ - detected object's attributes hash
14
+ # * +validation_errors+ - list of validation errors for invalid object
15
+ #
16
+ # Instances of this class are built via Import's +build_imported_object+ method.
17
+ #
18
+ # If you need you can implement your own version of ImportedObject class
19
+ # (f.e. activerecord-based). To use it you must also implement custom version of
20
+ # +Import+ class that builds instances of CustomImportedObject with it's
21
+ # +build_imported_object+ method.
22
+ class ImportedObject
23
+ attr_accessor :state, :object, :data, :validation_errors
3
24
 
25
+ def initialize(attributes = {})
26
+ @state = attributes[:state]
27
+ @object = attributes[:object]
28
+ @data = attributes[:data]
29
+ @validation_errors = attributes[:validation_errors]
30
+ end
4
31
  end
5
- end
32
+ end
@@ -2,6 +2,10 @@ module Importer
2
2
  module Parser
3
3
  # Extend this class if you want to provide a custom parser.
4
4
  # You only need to implement +run+ instance method in subclasses.
5
+ #
6
+ # To force the importer to use your custom parser use:
7
+ #
8
+ # Product.import(file, :parser => CustomParserClass)
5
9
  class Base
6
10
 
7
11
  class << self
@@ -1,9 +1,9 @@
1
1
  require 'fastercsv'
2
2
 
3
- # CSV parser
4
- # It uses fastercsv lib to parse the files.
5
3
  module Importer
6
4
  module Parser
5
+ # CSV parser
6
+ # Uses fastercsv lib to parse the CSV files.
7
7
  class Csv < Base
8
8
  def run
9
9
  @data = []
@@ -1,9 +1,9 @@
1
1
  require 'crack/xml'
2
2
 
3
- # XML parser
4
- # It uses crack/xml lib to parse the files.
5
3
  module Importer
6
4
  module Parser
5
+ # XML parser
6
+ # Uses crack/xml lib to parse the XML files.
7
7
  class Xml < Base
8
8
  def run
9
9
  @data = []
@@ -1,11 +1,2 @@
1
- Factory.define :active_record_import, :class => Importer::Import::ActiveRecord do |f|
2
- end
3
-
4
- Factory.define :active_record_imported_object, :class => Importer::ImportedObject::ActiveRecord do |f|
5
- end
6
-
7
- Factory.define :simple_import, :class => Importer::Import::Simple do |f|
8
- end
9
-
10
1
  Factory.define :product do |f|
11
2
  end
@@ -1,46 +1,23 @@
1
1
  require 'rubygems'
2
2
  require 'test/unit'
3
3
  require 'shoulda'
4
+ require 'rr'
5
+ require 'active_record'
6
+ require 'mongo_mapper'
4
7
 
5
8
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
9
  $LOAD_PATH.unshift(File.dirname(__FILE__))
10
+
7
11
  require 'importer'
8
12
 
9
13
  config = YAML::load(IO.read(File.join(File.dirname(__FILE__), 'database.yml')))
10
14
  ActiveRecord::Base.establish_connection(config['test'])
11
15
 
12
- require 'factory_girl'
13
- require 'factories'
16
+ MongoMapper.connection = Mongo::Connection.new('127.0.0.1', 27017)
17
+ MongoMapper.database = 'importer-test'
14
18
 
15
19
  class Test::Unit::TestCase
16
- def setup
17
- reset_tables
18
- end
19
-
20
- def reset_tables
21
- ActiveRecord::Base.connection.create_table :products, { :force => true } do |t|
22
- t.string :customid
23
- t.string :name
24
- t.string :description
25
- t.decimal :price
26
- end
27
-
28
- ActiveRecord::Base.connection.create_table :imports, { :force => true } do |t|
29
- t.integer :new_objects_count, :default => 0
30
- t.integer :existing_objects_count, :default => 0
31
- t.integer :invalid_objects_count, :default => 0
32
- t.string :workflow_state, :default => "ready"
33
- end
34
-
35
- ActiveRecord::Base.connection.create_table :imported_objects, { :force => true } do |t|
36
- t.integer :import_id
37
- t.string :object_type
38
- t.integer :object_id
39
- t.string :data
40
- t.string :validation_errors
41
- t.string :state
42
- end
43
- end
20
+ include RR::Adapters::TestUnit
44
21
 
45
22
  # return path to a fixture file from test/fixtures dir
46
23
  def fixture_file(file)
@@ -48,24 +25,13 @@ class Test::Unit::TestCase
48
25
  end
49
26
  end
50
27
 
51
- class Product < ActiveRecord::Base
52
- include Importer
53
-
54
- validates_numericality_of :price
55
-
56
- def self.find_on_import(import, attributes)
57
- find_by_customid(attributes["customid"])
58
- end
28
+ def def_class(class_name, parent_class = Object, &blk)
29
+ undef_class(class_name)
30
+ klass = Object.const_set(class_name, Class.new(parent_class))
31
+ klass.module_eval(&blk)
32
+ klass
59
33
  end
60
34
 
61
- class InvalidProduct < Product
62
- set_table_name "products"
63
-
64
- def self.find_on_import(import, attributes)
65
- if attributes["customid"] == "3"
66
- raise ::Exception.new("An error occured.")
67
- else
68
- super
69
- end
70
- end
35
+ def undef_class(class_name)
36
+ Object.send(:remove_const, class_name) rescue nil
71
37
  end
@@ -0,0 +1,114 @@
1
+ require 'helper'
2
+
3
+ class Importer::Adapters::ActiveRecordAdapterTest < Test::Unit::TestCase
4
+ def setup
5
+ ActiveRecord::Base.connection.create_table :products, { :force => true } do |t|
6
+ t.string :customid
7
+ t.string :name
8
+ t.string :description
9
+ t.decimal :price
10
+ end
11
+
12
+ def_class("Product", ActiveRecord::Base) do
13
+ include Importer
14
+
15
+ validates_numericality_of :price
16
+
17
+ def self.find_on_import(import, attributes)
18
+ find_by_customid(attributes["customid"])
19
+ end
20
+ end
21
+ end
22
+
23
+ def teardown
24
+ undef_class("Product")
25
+ end
26
+
27
+ context "" do
28
+ setup do
29
+ @product = Product.create(:customid => "1", :name => "A pink ball", :description => "Round glass ball.", :price => 86)
30
+ end
31
+
32
+ context "importing objects" do
33
+ setup do
34
+ @new_object = { "name" => "A red hat", "customid"=>"2", "price" => "114.00", "description" => "Party hat." }
35
+ @existing_object = { "name" => "A black ball", "customid"=>"1", "price" => "86.00", "description" => "Round glass ball." }
36
+ @invalid_object = { "name" => "A white ribbon", "customid"=>"3", "price" => "oops", "description" => "A really long one." }
37
+
38
+ data = [ @new_object, @existing_object, @invalid_object ]
39
+
40
+ stub(Importer::Parser::Xml).run(fixture_file("products.xml")) { data }
41
+
42
+ @import = Product.import(fixture_file("products.xml"))
43
+ end
44
+
45
+ should_change("product's name", :from => "A pink ball", :to => "A black ball") { @product.reload.name }
46
+
47
+ should_change("products count", :by => 1) { Product.count }
48
+ should "correctly create new product" do
49
+ product = Product.last
50
+
51
+ assert_equal "A red hat", product.name
52
+ assert_equal "Party hat.", product.description
53
+ assert_equal 114, product.price
54
+ assert_equal "2", product.customid
55
+ end
56
+
57
+ should "correctly summarize the import process" do
58
+ assert_equal 1, @import.new_imported_objects.size
59
+ assert_equal 1, @import.existing_imported_objects.size
60
+ assert_equal 1, @import.invalid_imported_objects.size
61
+ end
62
+
63
+ should "correctly build imported objects" do
64
+ new_object = @import.new_imported_objects.first
65
+ existing_object = @import.existing_imported_objects.first
66
+ invalid_object = @import.invalid_imported_objects.first
67
+
68
+ assert_equal @new_object, new_object.data
69
+ assert_equal 'new_object', new_object.state
70
+ assert_equal Product.last, new_object.object
71
+
72
+ assert_equal @existing_object, existing_object.data
73
+ assert_equal 'existing_object', existing_object.state
74
+ assert_equal @product, existing_object.object
75
+
76
+ assert_equal @invalid_object, invalid_object.data
77
+ assert_equal 'invalid_object', invalid_object.state
78
+ assert_equal ["Price is not a number"], invalid_object.validation_errors
79
+ end
80
+ end
81
+
82
+ context "when there is exception during import process" do
83
+ setup do
84
+ def_class("InvalidProduct", Product) do
85
+ set_table_name "products"
86
+
87
+ def self.find_on_import(import, attributes)
88
+ if attributes["customid"] == "3"
89
+ raise ::Exception.new("An error occured.")
90
+ else
91
+ super
92
+ end
93
+ end
94
+ end
95
+
96
+ begin
97
+ InvalidProduct.import(fixture_file("products.xml"))
98
+ rescue ::Exception => e
99
+ @exception = e
100
+ end
101
+ end
102
+
103
+ def teardown
104
+ undef_class("InvalidProduct")
105
+ end
106
+
107
+ should_not_change("product's name") { @product.reload.name }
108
+ should_not_change("products count") { InvalidProduct.count }
109
+ should "propagate exception" do
110
+ assert_equal "An error occured.", @exception.message
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,115 @@
1
+ require 'helper'
2
+
3
+ class Importer::Adapters::MongoMapperAdapterTest < Test::Unit::TestCase
4
+ def setup
5
+ def_class("Product") do
6
+ include MongoMapper::Document
7
+ include Importer
8
+
9
+ key :customid, String
10
+ key :name, String
11
+ key :description, String
12
+ key :price, Float
13
+
14
+ validates_numericality_of :price
15
+
16
+ def self.find_on_import(import, attributes)
17
+ find_by_customid(attributes["customid"])
18
+ end
19
+ end
20
+ end
21
+
22
+ def teardown
23
+ MongoMapper.database.drop_collection("products")
24
+ undef_class("Product")
25
+ end
26
+
27
+ context "" do
28
+ setup do
29
+ @product = Product.create(:customid => "1", :name => "A pink ball", :description => "Round glass ball.", :price => 86)
30
+ end
31
+
32
+ context "importing objects" do
33
+ setup do
34
+ @new_object = { "name" => "A red hat", "customid"=>"2", "price" => "114.00", "description" => "Party hat." }
35
+ @existing_object = { "name" => "A black ball", "customid"=>"1", "price" => "86.00", "description" => "Round glass ball." }
36
+ @invalid_object = { "name" => "A white ribbon", "customid"=>"3", "price" => "oops", "description" => "A really long one." }
37
+
38
+ data = [ @new_object, @existing_object, @invalid_object ]
39
+
40
+ stub(Importer::Parser::Xml).run(fixture_file("products.xml")) { data }
41
+
42
+ @import = Product.import(fixture_file("products.xml"))
43
+ end
44
+
45
+ should_change("product's name", :from => "A pink ball", :to => "A black ball") { @product.reload.name }
46
+
47
+ should_change("products count", :by => 1) { Product.count }
48
+ should "correctly create new product" do
49
+ product = Product.find_by_customid("2")
50
+
51
+ assert_equal "A red hat", product.name
52
+ assert_equal "Party hat.", product.description
53
+ assert_equal 114, product.price
54
+ assert_equal "2", product.customid
55
+ end
56
+
57
+ should "correctly summarize the import process" do
58
+ assert_equal 1, @import.new_imported_objects.size
59
+ assert_equal 1, @import.existing_imported_objects.size
60
+ assert_equal 1, @import.invalid_imported_objects.size
61
+ end
62
+
63
+ should "correctly build imported objects" do
64
+ new_object = @import.new_imported_objects.first
65
+ existing_object = @import.existing_imported_objects.first
66
+ invalid_object = @import.invalid_imported_objects.first
67
+
68
+ assert_equal @new_object, new_object.data
69
+ assert_equal 'new_object', new_object.state
70
+ assert_equal Product.find_by_customid("2"), new_object.object
71
+
72
+ assert_equal @existing_object, existing_object.data
73
+ assert_equal 'existing_object', existing_object.state
74
+ assert_equal @product, existing_object.object
75
+
76
+ assert_equal @invalid_object, invalid_object.data
77
+ assert_equal 'invalid_object', invalid_object.state
78
+ assert_equal ["Price must be a number"], invalid_object.validation_errors
79
+ end
80
+ end
81
+
82
+ context "when there is exception during import process" do
83
+ setup do
84
+ def_class("InvalidProduct", Product) do
85
+ include MongoMapper::Document
86
+ include Importer
87
+
88
+ set_collection_name "products"
89
+
90
+ def self.find_on_import(import, attributes)
91
+ if attributes["customid"] == "3"
92
+ raise ::Exception.new("An error occured.")
93
+ else
94
+ super
95
+ end
96
+ end
97
+ end
98
+
99
+ begin
100
+ InvalidProduct.import(fixture_file("products.xml"))
101
+ rescue ::Exception => e
102
+ @exception = e
103
+ end
104
+ end
105
+
106
+ def teardown
107
+ undef_class("InvalidProduct")
108
+ end
109
+
110
+ should "propagate exception" do
111
+ assert_equal "An error occured.", @exception.message
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ require 'helper'
2
+
3
+ class Importer::ImportTest < Test::Unit::TestCase
4
+ def setup
5
+ @import = Importer::Import.new
6
+ end
7
+
8
+ context "when sent #build_imported_object" do
9
+ setup do
10
+ @imported_object = @import.build_imported_object
11
+ end
12
+
13
+ should "correctly return instance of Importer::ImportedObject" do
14
+ assert @imported_object.is_a?(Importer::ImportedObject)
15
+ end
16
+ end
17
+
18
+ context "import with some imported_object" do
19
+ setup do
20
+ @new_object = Importer::ImportedObject.new(:state => 'new_object')
21
+ @existing_object = Importer::ImportedObject.new(:state => 'existing_object')
22
+ @invalid_object = Importer::ImportedObject.new(:state => 'invalid_object')
23
+
24
+ @import.add_object(@new_object)
25
+ @import.add_object(@existing_object)
26
+ @import.add_object(@invalid_object)
27
+ end
28
+
29
+ should "correctly return all imported objects" do
30
+ assert_same_elements [@new_object, @existing_object, @invalid_object], @import.imported_objects
31
+ end
32
+
33
+ should "correctly return new_imported_objects" do
34
+ assert_same_elements [@new_object], @import.new_imported_objects
35
+ end
36
+
37
+ should "correctly return existing_imported_objects" do
38
+ assert_same_elements [@existing_object], @import.existing_imported_objects
39
+ end
40
+
41
+ should "correctly return invalid_imported_objects" do
42
+ assert_same_elements [@invalid_object], @import.invalid_imported_objects
43
+ end
44
+ end
45
+ end