importer 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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