importer 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +70 -0
- data/Rakefile +60 -0
- data/VERSION +1 -0
- data/importer.gemspec +114 -0
- data/lib/importer.rb +102 -0
- data/lib/importer/import.rb +5 -0
- data/lib/importer/import/active_record.rb +37 -0
- data/lib/importer/import/simple.rb +57 -0
- data/lib/importer/imported_object.rb +7 -0
- data/lib/importer/imported_object/active_record.rb +41 -0
- data/lib/importer/imported_object/simple.rb +25 -0
- data/lib/importer/parser.rb +22 -0
- data/lib/importer/parser/base.rb +31 -0
- data/lib/importer/parser/csv.rb +24 -0
- data/lib/importer/parser/xml.rb +25 -0
- data/rails/init.rb +1 -0
- data/rails_generators/importer/importer_generator.rb +18 -0
- data/rails_generators/importer/templates/imported_objects_migration.rb +19 -0
- data/rails_generators/importer/templates/imports_migration.rb +17 -0
- data/test/database.yml +3 -0
- data/test/factories.rb +11 -0
- data/test/fixtures/empty.csv +0 -0
- data/test/fixtures/empty.xml +3 -0
- data/test/fixtures/product.csv +2 -0
- data/test/fixtures/product.xml +9 -0
- data/test/fixtures/products.csv +4 -0
- data/test/fixtures/products.xml +21 -0
- data/test/helper.rb +71 -0
- data/test/importer/import/active_record_test.rb +27 -0
- data/test/importer/import/simple_test.rb +27 -0
- data/test/importer/imported_object/active_record_test.rb +37 -0
- data/test/importer/imported_object/simple_test.rb +35 -0
- data/test/importer/imported_object_test.rb +13 -0
- data/test/importer/parser/csv_test.rb +59 -0
- data/test/importer/parser/xml_test.rb +58 -0
- data/test/importer/parser_test.rb +13 -0
- data/test/importer_test.rb +50 -0
- 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,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
|
data/rails/init.rb
ADDED
@@ -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
|
data/test/database.yml
ADDED
data/test/factories.rb
ADDED
@@ -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,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>
|
data/test/helper.rb
ADDED
@@ -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
|