act_as_importable 0.0.7 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ ## 0.0.8 (April 7, 2013)
2
+
3
+ - substantial refactoring
4
+ - moved import functionality into its own class (Importer)
5
+ - changed the return type of the import methods to be an instance of the Importer class
data/README.md CHANGED
@@ -28,6 +28,8 @@ User.import_csv_file('/path/to/file.csv')
28
28
  User.import_csv_text(csv_text)
29
29
  # or
30
30
  User.import_data(array_of_hashes)
31
+ # or
32
+ User.import_record(hash)
31
33
  ```
32
34
 
33
35
  ## CSV File Format
@@ -1,106 +1,45 @@
1
1
  require 'active_support'
2
- require 'csv'
2
+ require 'act_as_importable/importer'
3
+ require 'act_as_importable/csv_importer'
3
4
 
4
5
  module ActAsImportable
5
6
  module Base
6
7
  extend ActiveSupport::Concern
7
8
 
9
+ included do
10
+
11
+ # This is used to store the data being import when there is an error
12
+ attr_accessor :import_data
13
+
14
+ end
15
+
8
16
  module ClassMethods
9
17
 
10
18
  def import_csv_file(file, options = {})
11
- import_csv_text(read_file(file, options), options)
19
+ options.reverse_merge!(@default_import_options)
20
+ importer = ActAsImportable::CSVImporter.new(options)
21
+ importer.import_csv_file(file)
22
+ importer
12
23
  end
13
24
 
14
25
  def import_csv_text(text, options = {})
15
- csv = ::CSV.parse(text, :headers => true)
16
- csv.map do |row|
17
- import_record(row.to_hash, options)
18
- end
26
+ options.reverse_merge!(@default_import_options)
27
+ importer = ActAsImportable::CSVImporter.new(options)
28
+ importer.import_csv_text(text)
29
+ importer
19
30
  end
20
31
 
21
32
  def import_data(data, options = {})
22
- data.map do |row|
23
- import_record(row, options)
24
- end
33
+ options.reverse_merge!(@default_import_options)
34
+ importer = ActAsImportable::Importer.new(options)
35
+ importer.import_data(data)
36
+ importer
25
37
  end
26
38
 
27
- # Creates or updates a model record
28
- # Existing records are found by the column(s) specified by the :uid option (default 'id').
29
- # If the values for the uid columns are not provided the row will be ignored.
30
- # If uid is set to nil it will import the row data as a new record.
31
39
  def import_record(row, options = {})
32
40
  options.reverse_merge!(@default_import_options)
33
- row = row.with_indifferent_access
34
- row.reverse_merge!(options[:default_values]) if options[:default_values]
35
- convert_key_paths_to_values!(row)
36
- row = filter_columns(row, options)
37
- record = find_or_create_by_uids(uid_values(row, options))
38
- remove_uid_values_from_row(row, options)
39
- record.update_attributes(row)
40
- unless record.save
41
- Rails.logger.error(record.errors.full_messages)
42
- end
43
- record
44
- end
45
-
46
- def filter_columns(row, options = {})
47
- except = Array(options[:except]).map { |i| i.to_s }
48
- only = Array(options[:only]).map { |i| i.to_s }
49
- row = row.reject { |key, value| except.include? key.to_s } if except.present?
50
- row = row.select { |key, value| only.include? key.to_s } if only.present?
51
- row
52
- end
53
-
54
- def uid_values(row, options)
55
- Hash[Array(options[:uid]).map { |k| [k, row[k.to_sym]] }]
56
- end
57
-
58
- def remove_uid_values_from_row(row, options = {})
59
- Array(options[:uid]).each do |field|
60
- row.delete(field)
61
- end
62
- end
63
-
64
- def find_association_value_with_attribute(name, attribute)
65
- association = self.reflect_on_association(name.to_sym)
66
- association.klass.where(attribute).first
67
- end
68
-
69
- def find_or_create_by_uids(attributes, &block)
70
- find_by_uids(attributes) || create(attributes, &block)
71
- end
72
-
73
- def find_by_uids(attributes)
74
- attributes.inject(self.scoped.readonly(false)) { |scope, key_value|
75
- add_scope_for_field(scope, key_value[0].to_s, key_value[1])
76
- }.first
77
- end
78
-
79
- def add_scope_for_field(scope, field, value)
80
- return scope unless value
81
- if (association = self.reflect_on_association(field.to_sym))
82
- field = association.foreign_key
83
- value = value.id
84
- end
85
- scope.where("#{self.table_name}.#{field} = ?", value)
86
- end
87
-
88
- # TODO: update this to support finding the association value with multiple columns
89
- def convert_key_paths_to_values!(row)
90
- key_path_attributes = row.select { |k,v| k.to_s.include? '.' }
91
- key_path_attributes.each do |key, value|
92
- association_name, uid_field = key.to_s.split('.')
93
- row[association_name.to_sym] = find_association_value_with_attribute(association_name, uid_field => value)
94
- row.delete(key.to_sym)
95
- end
96
- end
97
-
98
- def read_file(file, options = {})
99
- if options[:encoding]
100
- File.read(file, :encoding => options[:encoding])
101
- else
102
- File.read(file)
103
- end
41
+ importer = ActAsImportable::Importer.new(options)
42
+ importer.import_record(row)
104
43
  end
105
44
 
106
45
  end
@@ -10,6 +10,7 @@ module ActAsImportable::Config
10
10
 
11
11
  @default_import_options = options
12
12
  @default_import_options[:uid] ||= :id
13
+ @default_import_options[:model_class] ||= self
13
14
 
14
15
  # create a reader on the class to access the field name
15
16
  class << self;
@@ -0,0 +1,18 @@
1
+ require 'csv'
2
+
3
+ module ActAsImportable
4
+ class CSVImporter < ActAsImportable::Importer
5
+
6
+ def import_csv_file(file)
7
+ import_csv_text(File.read(file, options))
8
+ end
9
+
10
+ def import_csv_text(text)
11
+ csv = ::CSV.parse(text, :headers => options[:headers] || true)
12
+ csv.each do |row|
13
+ import_record(row.to_hash)
14
+ end
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,150 @@
1
+ module ActAsImportable
2
+ class Importer
3
+
4
+ def initialize(options = {})
5
+ @default_options = options
6
+ @imported_records = []
7
+ @errors = []
8
+ end
9
+
10
+ def options
11
+ @default_options
12
+ end
13
+
14
+ def import_data(data)
15
+ data.map do |row|
16
+ import_record(row)
17
+ end
18
+ end
19
+
20
+ def import_record(row)
21
+ row = prepare_row_for_import(row)
22
+ record = find_or_create_record(row)
23
+ record.update_attributes(row)
24
+ record.save
25
+ imported_records << record
26
+ record
27
+ rescue Exception => e
28
+ record = model_class.new
29
+ # Assign the valid attributes (without saving)
30
+ record.assign_attributes(row.select { |k,v| record.attributes.keys.include? k })
31
+ record.errors.add :base, e.message
32
+ record.import_data = row if record.respond_to? :import_data=
33
+ imported_records << record
34
+ record
35
+ end
36
+
37
+ def missing_uid_values(row)
38
+ uid_values(row).select { |k, v| v.blank? }
39
+ end
40
+
41
+ def imported_records
42
+ @imported_records ||= []
43
+ end
44
+
45
+ def successful_imports
46
+ # Note: We don't want to re-validate the objects.
47
+ imported_records.select { |r| r.errors.empty? }
48
+ end
49
+
50
+ def failed_imports
51
+ # Note: We don't want to re-validate the objects.
52
+ imported_records.reject { |r| r.errors.empty? }
53
+ end
54
+
55
+ def model_class
56
+ options[:model_class]
57
+ end
58
+
59
+ def filter_columns(row)
60
+ except = Array(options[:except]).map { |i| i.to_s }
61
+ only = Array(options[:only]).map { |i| i.to_s }
62
+ row.reject! { |key, value| except.include? key.to_s } if except.present?
63
+ row.select! { |key, value| only.include? key.to_s } if only.present?
64
+ row
65
+ end
66
+
67
+ private
68
+
69
+ def prepare_row_for_import(row)
70
+ row = row.with_indifferent_access
71
+ add_default_values_to_row(row)
72
+ convert_key_paths_to_values(row)
73
+ filter_columns(row)
74
+ row
75
+ end
76
+
77
+ def add_default_values_to_row(row)
78
+ row.reverse_merge!(options[:default_values]) if options[:default_values]
79
+ end
80
+
81
+ def uid_keys
82
+ Array(options[:uid])
83
+ end
84
+
85
+ def uid_values(row)
86
+ uid_keys.each_with_object({}) do |key, result|
87
+ result[key] = row[key.to_sym]
88
+ end
89
+ end
90
+
91
+ def remove_uid_values_from_row(row, options = {})
92
+ Array(options[:uid]).each do |field|
93
+ row.delete(field)
94
+ end
95
+ end
96
+
97
+ def find_association_value_with_attribute(name, attribute)
98
+ association = model_class.reflect_on_association(name.to_sym)
99
+ association.klass.where(attribute).first
100
+ end
101
+
102
+ def find_or_create_record(row)
103
+ if missing_uid_values(row).present?
104
+ raise "Missing the following uids attributes. #{missing_uid_values(row).keys}"
105
+ end
106
+
107
+ record = find_or_create_by_uids(uid_values(row))
108
+ remove_uid_values_from_row(row)
109
+ record
110
+ end
111
+
112
+ def find_or_create_by_uids(attributes, &block)
113
+ find_by_uids(attributes) || model_class.create(attributes, &block)
114
+ end
115
+
116
+ def find_by_uids(attributes)
117
+ results = attributes.inject(model_class.scoped.readonly(false)) { |scope, key_value|
118
+ add_scope_for_field(scope, key_value[0].to_s, key_value[1])
119
+ }
120
+ if results.count > 1
121
+ raise "Multiple records found with uid attributes. Attributes: #{attributes}"
122
+ end
123
+ results.first
124
+ end
125
+
126
+ def add_scope_for_field(scope, field, value)
127
+ return scope unless value
128
+ if (association = model_class.reflect_on_association(field.to_sym))
129
+ field = association.foreign_key
130
+ value = value.id
131
+ end
132
+ scope.where("#{model_class.table_name}.#{field} = ?", value)
133
+ end
134
+
135
+ # TODO: update this to support finding the association value with multiple columns
136
+ def convert_key_paths_to_values(row)
137
+ key_path_attributes = row.select { |k, v| k.to_s.include? '.' }
138
+ key_path_attributes.each do |key, value|
139
+ association_name, uid_field = key.to_s.split('.')
140
+ association_value = find_association_value_with_attribute(association_name, uid_field => value)
141
+ if association_value.blank?
142
+ raise "Failed to find #{association_name} with #{uid_field} = #{value}"
143
+ end
144
+ row[association_name.to_sym] = association_value
145
+ row.delete(key.to_sym)
146
+ end
147
+ end
148
+
149
+ end
150
+ end
@@ -1,3 +1,3 @@
1
1
  module ActAsImportable
2
- VERSION = "0.0.7"
2
+ VERSION = "0.0.8"
3
3
  end
@@ -1,31 +1,5 @@
1
1
  require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
2
 
3
- # create the tables for the tests
4
- ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS 'categories'")
5
- ActiveRecord::Base.connection.create_table(:categories) do |t|
6
- t.string :name
7
- end
8
-
9
- class Category < ActiveRecord::Base
10
- has_many :items
11
- end
12
-
13
- ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS 'items'")
14
- ActiveRecord::Base.connection.create_table(:items) do |t|
15
- t.integer :category_id
16
- t.string :name
17
- t.float :price
18
- end
19
-
20
- class Item < ActiveRecord::Base
21
- # This line isn't needed in a real Rails app.
22
- include ActAsImportable::Config
23
-
24
- act_as_importable :uid => 'name'
25
-
26
- belongs_to :category
27
- end
28
-
29
3
  describe "an act_as_importable model" do
30
4
 
31
5
  before(:each) do
@@ -45,7 +19,7 @@ describe "an act_as_importable model" do
45
19
  it { should respond_to :import_record }
46
20
  it { should respond_to :default_import_options }
47
21
 
48
- let(:default_options) { {:uid => 'name'} }
22
+ let(:default_options) { {:uid => 'name', :model_class => Item} }
49
23
 
50
24
  it "should have the correct default import options" do
51
25
  Item.default_import_options.should == default_options
@@ -54,11 +28,11 @@ describe "an act_as_importable model" do
54
28
  describe "import csv file" do
55
29
  let(:file) { 'spec/fixtures/items.csv' }
56
30
  it 'should call import_text with contents of file' do
57
- Item.should_receive(:import_csv_text).with(File.read(file), {})
31
+ ActAsImportable::CSVImporter.any_instance.should_receive(:import_csv_text).with(File.read(file))
58
32
  Item.import_csv_file(file)
59
33
  end
60
- it "should return an array of imported records" do
61
- Item.import_csv_file(file).should == Item.all.to_a
34
+ it "should return an importer instance" do
35
+ Item.import_csv_file(file).should be_a ActAsImportable::Importer
62
36
  end
63
37
  end
64
38
 
@@ -66,13 +40,23 @@ describe "an act_as_importable model" do
66
40
  let(:text) { "name,price\nBeer,2.5\nApple,0.5" }
67
41
 
68
42
  it 'should call import_record with row hashes' do
69
- Item.should_receive(:import_record).with({'name' => 'Beer', 'price' => '2.5'}, {}).once
70
- Item.should_receive(:import_record).with({'name' => 'Apple', 'price' => '0.5'}, {}).once
43
+ ActAsImportable::CSVImporter.any_instance.should_receive(:import_record).with({'name' => 'Beer', 'price' => '2.5'}).once
44
+ ActAsImportable::CSVImporter.any_instance.should_receive(:import_record).with({'name' => 'Apple', 'price' => '0.5'}).once
71
45
  Item.import_csv_text(text)
72
46
  end
73
47
 
74
- it "should return an array of imported records" do
75
- Item.import_csv_text(text).should == Item.all.to_a
48
+ it "should return an importer instance" do
49
+ Item.import_csv_text(text).should be_a ActAsImportable::Importer
50
+ end
51
+
52
+ it "should import 2 records successfully" do
53
+ result = Item.import_csv_text(text)
54
+ result.successful_imports.count.should == 2
55
+ end
56
+
57
+ it "should have no errors" do
58
+ result = Item.import_csv_text(text)
59
+ result.failed_imports.count.should == 0
76
60
  end
77
61
  end
78
62
 
@@ -82,13 +66,23 @@ describe "an act_as_importable model" do
82
66
  let(:data) { [beer, apple] }
83
67
 
84
68
  it 'should call import_record with row hashes' do
85
- Item.should_receive(:import_record).with(beer, {}).once
86
- Item.should_receive(:import_record).with(apple, {}).once
69
+ ActAsImportable::Importer.any_instance.should_receive(:import_record).with(beer).once
70
+ ActAsImportable::Importer.any_instance.should_receive(:import_record).with(apple).once
87
71
  Item.import_data(data)
88
72
  end
89
73
 
90
- it "should return an array of imported records" do
91
- Item.import_data(data).should == Item.all.to_a
74
+ it "should return an importer instance" do
75
+ Item.import_data(data).should be_a ActAsImportable::Importer
76
+ end
77
+
78
+ it "should import 2 records successfully" do
79
+ result = Item.import_data(data)
80
+ result.successful_imports.count.should == 2
81
+ end
82
+
83
+ it "should have no errors" do
84
+ result = Item.import_data(data)
85
+ result.failed_imports.count.should == 0
92
86
  end
93
87
  end
94
88
 
@@ -113,7 +107,7 @@ describe "an act_as_importable model" do
113
107
  Item.import_record(row, :uid => :name)
114
108
  @existing_item.reload.price.should == 2.5
115
109
  end
116
- it "should not create a new item" do
110
+ it "should not create a new record" do
117
111
  expect { Item.import_record(row, :uid => 'name') }.to change { Item.count }.by(0)
118
112
  end
119
113
  end
@@ -208,26 +202,5 @@ describe "an act_as_importable model" do
208
202
 
209
203
  end
210
204
 
211
- describe "#filter_columns" do
212
- let(:row) { {:name => 'Beer', :price => 2.5}.with_indifferent_access }
213
-
214
- it 'should filter columns when importing each record' do
215
- Item.should_receive(:filter_columns).with(row, default_options).and_return(row)
216
- Item.import_record(row)
217
- end
218
-
219
- it "should not modify row if no options provided" do
220
- Item.filter_columns(row).should == row
221
- end
222
-
223
- it "should remove columns specified by the :except option" do
224
- Item.filter_columns(row, :except => :price).should == {'name' => 'Beer'}
225
- end
226
-
227
- it "should remove columns not specified by the :only option" do
228
- Item.filter_columns(row, :only => :price).should == {'price' => 2.5}
229
- end
230
- end
231
-
232
205
  end
233
206
 
@@ -0,0 +1,38 @@
1
+ describe ActAsImportable::Importer do
2
+
3
+ before(:each) do
4
+ ActiveRecord::Base.connection.increment_open_transactions
5
+ ActiveRecord::Base.connection.begin_db_transaction
6
+ end
7
+
8
+ after(:each) do
9
+ ActiveRecord::Base.connection.rollback_db_transaction
10
+ ActiveRecord::Base.connection.decrement_open_transactions
11
+ end
12
+
13
+ describe "#filter_columns" do
14
+ let(:row) { {:name => 'Beer', :price => 2.5}.with_indifferent_access }
15
+
16
+ it 'should filter columns when importing each record' do
17
+ importer = ActAsImportable::Importer.new(:uid => :name, :model_class => Item)
18
+ importer.should_receive(:filter_columns).with(row).and_return(row)
19
+ importer.import_record(row)
20
+ end
21
+
22
+ it "should not modify row if no options provided" do
23
+ importer = ActAsImportable::Importer.new
24
+ importer.filter_columns(row).should == row
25
+ end
26
+
27
+ it "should remove columns specified by the :except option" do
28
+ importer = ActAsImportable::Importer.new(:except => :price)
29
+ importer.filter_columns(row).should == {'name' => 'Beer'}
30
+ end
31
+
32
+ it "should remove columns not specified by the :only option" do
33
+ importer = ActAsImportable::Importer.new(:only => :price)
34
+ importer.filter_columns(row).should == {'price' => 2.5}
35
+ end
36
+ end
37
+
38
+ end
@@ -18,4 +18,30 @@ ActiveRecord::Base.establish_connection(
18
18
  )
19
19
 
20
20
  RSpec.configure do |config|
21
+ end
22
+
23
+ # create the tables for the tests
24
+ ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS 'categories'")
25
+ ActiveRecord::Base.connection.create_table(:categories) do |t|
26
+ t.string :name
27
+ end
28
+
29
+ class Category < ActiveRecord::Base
30
+ has_many :items
31
+ end
32
+
33
+ ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS 'items'")
34
+ ActiveRecord::Base.connection.create_table(:items) do |t|
35
+ t.integer :category_id
36
+ t.string :name
37
+ t.float :price
38
+ end
39
+
40
+ class Item < ActiveRecord::Base
41
+ # This line isn't needed in a real Rails app.
42
+ include ActAsImportable::Config
43
+
44
+ act_as_importable :uid => 'name'
45
+
46
+ belongs_to :category
21
47
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: act_as_importable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-03-23 00:00:00.000000000 Z
12
+ date: 2013-04-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -84,6 +84,7 @@ extra_rdoc_files: []
84
84
  files:
85
85
  - .gitignore
86
86
  - .rvmrc
87
+ - CHANGELOG.md
87
88
  - Gemfile
88
89
  - LICENSE.txt
89
90
  - README.md
@@ -93,9 +94,12 @@ files:
93
94
  - lib/act_as_importable.rb
94
95
  - lib/act_as_importable/base.rb
95
96
  - lib/act_as_importable/config.rb
97
+ - lib/act_as_importable/csv_importer.rb
98
+ - lib/act_as_importable/importer.rb
96
99
  - lib/act_as_importable/railtie.rb
97
100
  - lib/act_as_importable/version.rb
98
101
  - spec/act_as_importable/act_as_importable_spec.rb
102
+ - spec/act_as_importable/importer_spec.rb
99
103
  - spec/fixtures/items.csv
100
104
  - spec/spec_helper.rb
101
105
  homepage: https://github.com/Col/act_as_importable
@@ -124,5 +128,6 @@ specification_version: 3
124
128
  summary: Helps import records from CSV files.
125
129
  test_files:
126
130
  - spec/act_as_importable/act_as_importable_spec.rb
131
+ - spec/act_as_importable/importer_spec.rb
127
132
  - spec/fixtures/items.csv
128
133
  - spec/spec_helper.rb