act_as_importable 0.0.7 → 0.0.8

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