importer 0.3.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 (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