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.
- 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
|