importer 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.document +5 -0
  2. data/.gitignore +21 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +70 -0
  5. data/Rakefile +60 -0
  6. data/VERSION +1 -0
  7. data/importer.gemspec +114 -0
  8. data/lib/importer.rb +102 -0
  9. data/lib/importer/import.rb +5 -0
  10. data/lib/importer/import/active_record.rb +37 -0
  11. data/lib/importer/import/simple.rb +57 -0
  12. data/lib/importer/imported_object.rb +7 -0
  13. data/lib/importer/imported_object/active_record.rb +41 -0
  14. data/lib/importer/imported_object/simple.rb +25 -0
  15. data/lib/importer/parser.rb +22 -0
  16. data/lib/importer/parser/base.rb +31 -0
  17. data/lib/importer/parser/csv.rb +24 -0
  18. data/lib/importer/parser/xml.rb +25 -0
  19. data/rails/init.rb +1 -0
  20. data/rails_generators/importer/importer_generator.rb +18 -0
  21. data/rails_generators/importer/templates/imported_objects_migration.rb +19 -0
  22. data/rails_generators/importer/templates/imports_migration.rb +17 -0
  23. data/test/database.yml +3 -0
  24. data/test/factories.rb +11 -0
  25. data/test/fixtures/empty.csv +0 -0
  26. data/test/fixtures/empty.xml +3 -0
  27. data/test/fixtures/product.csv +2 -0
  28. data/test/fixtures/product.xml +9 -0
  29. data/test/fixtures/products.csv +4 -0
  30. data/test/fixtures/products.xml +21 -0
  31. data/test/helper.rb +71 -0
  32. data/test/importer/import/active_record_test.rb +27 -0
  33. data/test/importer/import/simple_test.rb +27 -0
  34. data/test/importer/imported_object/active_record_test.rb +37 -0
  35. data/test/importer/imported_object/simple_test.rb +35 -0
  36. data/test/importer/imported_object_test.rb +13 -0
  37. data/test/importer/parser/csv_test.rb +59 -0
  38. data/test/importer/parser/xml_test.rb +58 -0
  39. data/test/importer/parser_test.rb +13 -0
  40. data/test/importer_test.rb +50 -0
  41. metadata +184 -0
@@ -0,0 +1,57 @@
1
+ module Importer
2
+ module Import
3
+ # Simple import summary. It's not stored in database (as with +ActiveRecord+ import).
4
+ #
5
+ # Attributes:
6
+ # * +new_objects_count+ - number of new objects created during the import
7
+ # * +existing_objects_count+ - number of objects modified during the import
8
+ # * +invalid_objects_count+ - number of objects that couldn't have been imported
9
+ # * +workflow_state+ - import may be in one of three states: ready, started or
10
+ # finished. The state changes during the import process.
11
+ class Simple
12
+ attr_reader :state, :new_objects_count, :existing_objects_count, :invalid_objects_count, :imported_objects
13
+
14
+ class << self
15
+ def create
16
+ import = new
17
+ import.save
18
+ import
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @state = "ready"
24
+ @new_objects_count = 0
25
+ @existing_objects_count = 0
26
+ @invalid_objects_count = 0
27
+ @imported_objects = []
28
+ end
29
+
30
+ def start!
31
+ @state = "started"
32
+ end
33
+
34
+ def finish!
35
+ @state = "finished"
36
+ end
37
+
38
+ def add_object(imported_object)
39
+ case imported_object.state
40
+ when "new_object"
41
+ @new_objects_count += 1
42
+ when "existing_object"
43
+ @existing_objects_count += 1
44
+ when "invalid_object"
45
+ @invalid_objects_count += 1
46
+ end
47
+ @imported_objects << imported_object
48
+ end
49
+
50
+ def save
51
+ true
52
+ end
53
+
54
+ alias_method :save!, :save
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,7 @@
1
+ module Importer
2
+ module ImportedObject
3
+ def self.get_klass(import)
4
+ import.class.to_s.sub("Importer::Import", "Importer::ImportedObject").constantize
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ require 'active_record'
2
+
3
+ module Importer
4
+ module ImportedObject
5
+ # ActiveRecord model that stores detailed information of imported objects in
6
+ # imported_objects database table.
7
+ #
8
+ # belongs_to :import - reference to import instance
9
+ # belongs_to :object - reference to imported object
10
+ #
11
+ # Attributes:
12
+ # * +data+ - object's detected attributes hash
13
+ # * +validation_errors+ - object's validation errors hash.
14
+ class ActiveRecord < ::ActiveRecord::Base
15
+ set_table_name "imported_objects"
16
+
17
+ named_scope :new_objects, :conditions => { :state => "new_object" }
18
+ named_scope :existing_objects, :conditions => { :state => "existing_object" }
19
+ named_scope :invalid_objects, :conditions => { :state => "invalid_object" }
20
+
21
+ belongs_to :import, :class_name => "Importer::Import::ActiveRecord"
22
+ belongs_to :object, :polymorphic => true
23
+
24
+ after_save :increment_counter
25
+ after_destroy :decrement_counter
26
+
27
+ serialize :data
28
+ serialize :validation_errors
29
+
30
+ protected
31
+
32
+ def increment_counter
33
+ import.increment!("#{state}s_count") if import
34
+ end
35
+
36
+ def decrement_counter
37
+ import.decrement!("#{state}s_count") if import
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ module Importer
2
+ module ImportedObject
3
+ # Simple imported object details. It's not stored in database (as with
4
+ # +ActiveRecord+ imported object).
5
+ #
6
+ # Attributes:
7
+ # * +data+ - object's detected attributes hash
8
+ # * +validation_errors+ - object's validation errors hash
9
+ class Simple
10
+ attr_reader :import
11
+ attr_accessor :state, :object, :data, :validation_errors
12
+
13
+ def initialize(attributes = {})
14
+ raise ArgumentError.new(":import attribute is required.") unless attributes[:import]
15
+ @import = attributes[:import]
16
+ end
17
+
18
+ def save
19
+ import.add_object(self)
20
+ end
21
+
22
+ alias_method :save!, :save
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ module Importer
2
+ class ParserNotFoundError < ::Exception; end
3
+
4
+ # Determines the parser needed to parse given +file+ basing on +file+ extension.
5
+ # Return Xml parser for .xml files, Csv parser for .csv file and so on.
6
+ module Parser
7
+ def self.get_klass(file)
8
+ extension = File.extname(file)[1..-1]
9
+
10
+ if extension
11
+ klass = extension.camelize
12
+
13
+ if Importer::Parser.const_defined?(klass.to_sym)
14
+ klass = "Importer::Parser::#{klass}".constantize
15
+ return klass
16
+ end
17
+ end
18
+
19
+ raise Importer::ParserNotFoundError.new("Can't find #{klass} parser.")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ module Importer
2
+ module Parser
3
+ # Extend this class if you want to provide a custom parser.
4
+ # You only need to implement +run+ instance method in subclasses.
5
+ class Base
6
+
7
+ class << self
8
+ # Creates parser instance and processes the +file+
9
+ def run(file)
10
+ parser = new(file)
11
+ parser.run
12
+ parser.data
13
+ end
14
+ end
15
+
16
+ attr_reader :data
17
+
18
+ def initialize(file)
19
+ @file = file
20
+ @data = []
21
+ end
22
+
23
+ # This method must be implemented in subclasses.
24
+ # It is meant to process input @file, and store the results in @data instance
25
+ # variable.
26
+ def run
27
+ raise Exception.new("This method must be implemented in subclasses.")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ require 'fastercsv'
2
+
3
+ # CSV parser
4
+ # It uses fastercsv lib to parse the files.
5
+ module Importer
6
+ module Parser
7
+ class Csv < Base
8
+ def run
9
+ @data = []
10
+
11
+ data = FasterCSV.read(@file, :skip_blanks => true)
12
+
13
+ unless data.empty?
14
+ attributes = data.shift
15
+ @data = data.map do |values|
16
+ Hash[*attributes.zip(values).flatten]
17
+ end
18
+ end
19
+
20
+ @data
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ require 'crack/xml'
2
+
3
+ # XML parser
4
+ # It uses crack/xml lib to parse the files.
5
+ module Importer
6
+ module Parser
7
+ class Xml < Base
8
+ def run
9
+ @data = []
10
+
11
+ file = File.new(@file)
12
+ data = Crack::XML.parse(file)
13
+
14
+ root = data.shift[1]
15
+
16
+ if root
17
+ objects = root.shift[1]
18
+ @data = objects.is_a?(Hash) ? [objects] : objects
19
+ end
20
+
21
+ @data
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'importer')
@@ -0,0 +1,18 @@
1
+ module Rails
2
+ module Generator
3
+ class ImporterGenerator < NamedBase
4
+ def banner
5
+ "Usage: #{$0} importer import"
6
+ end
7
+
8
+ def manifest
9
+ record do |m|
10
+ time = Time.now
11
+ m.template "imports_migration.rb", "db/migrate/#{time.strftime('%Y%m%d%H%M%S')}_create_imports.rb"
12
+ time += 1
13
+ m.template "imported_objects_migration.rb", "db/migrate/#{time.strftime('%Y%m%d%H%M%S')}_create_imported_objects.rb"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ class CreateImportedObjects < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :imported_objects do |t|
4
+ t.integer :import_id, :null => false
5
+ t.string :object_type
6
+ t.integer :object_id
7
+ t.text :data
8
+ t.text :validation_errors
9
+ t.string :state, :null => false, :limit => 20
10
+ t.timestamps
11
+ end
12
+ add_index :imported_objects, :import_id
13
+ end
14
+
15
+ def self.down
16
+ remove_index :imported_objects, :import_id
17
+ drop_table :imported_objects
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ class CreateImports < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :imports do |t|
4
+ t.integer :new_objects_count, :null => false, :default => 0
5
+ t.integer :existing_objects_count, :null => false, :default => 0
6
+ t.integer :invalid_objects_count, :null => false, :default => 0
7
+ t.string :workflow_state, :null => false, :default => "ready", :limit => 10
8
+ t.timestamps
9
+ end
10
+ add_index :imports, :workflow_state
11
+ end
12
+
13
+ def self.down
14
+ remove_index :imports, :workflow_state
15
+ drop_table :imports
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
@@ -0,0 +1,11 @@
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
+ Factory.define :product do |f|
11
+ end
File without changes
@@ -0,0 +1,3 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <products>
3
+ </products>
@@ -0,0 +1,2 @@
1
+ customid,name,description,price
2
+ 1,A black ball,Round glass ball.,86.00
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <products>
3
+ <product>
4
+ <customid>1</customid>
5
+ <name>A black ball</name>
6
+ <description>Round glass ball.</description>
7
+ <price>86.00</price>
8
+ </product>
9
+ </products>
@@ -0,0 +1,4 @@
1
+ customid,name,description,price
2
+ 1,A black ball,Round glass ball.,86.00
3
+ 2,A red hat,Party hat.,114.00
4
+ 3,A white ribbon,A really long one.,oops
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <products>
3
+ <product>
4
+ <customid>1</customid>
5
+ <name>A black ball</name>
6
+ <description>Round glass ball.</description>
7
+ <price>86.00</price>
8
+ </product>
9
+ <product>
10
+ <customid>2</customid>
11
+ <name>A red hat</name>
12
+ <description>Party hat.</description>
13
+ <price>114.00</price>
14
+ </product>
15
+ <product>
16
+ <customid>3</customid>
17
+ <name>A white ribbon</name>
18
+ <description>A really long one.</description>
19
+ <price>oops</price>
20
+ </product>
21
+ </products>
@@ -0,0 +1,71 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+ require 'importer'
8
+
9
+ config = YAML::load(IO.read(File.join(File.dirname(__FILE__), 'database.yml')))
10
+ ActiveRecord::Base.establish_connection(config['test'])
11
+
12
+ require 'factory_girl'
13
+ require 'factories'
14
+
15
+ 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
44
+
45
+ # return path to a fixture file from test/fixtures dir
46
+ def fixture_file(file)
47
+ File.expand_path(File.dirname(__FILE__) + "/fixtures/#{file}")
48
+ end
49
+ end
50
+
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
59
+ end
60
+
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
71
+ end