activerecord_csv_importer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 62b46ebbaa1845d396dba93a645a0e380634c0f5
4
+ data.tar.gz: 6b3487666658e35b4b195bce03043165b1788675
5
+ SHA512:
6
+ metadata.gz: 044fab5881d472092998ea2ca7c893b86ac1ff1671b8380ed94462793b93b7088e22e90c1a3f7df5b5dbe69d192c8690c5edcf7aa6a3d9d1be2475af42ee1a2d
7
+ data.tar.gz: 5be82ce068a1f0651aca75317dbe094d372ad6ee34730cada3cdccd8ddcec95d2fca170a7e54eb4e2066479f961862aefd62f2eae2ffc4417395dd14e38be7b9
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .DS_Store
11
+ /spec/examples.txt
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ Style/Documentation:
2
+ Enabled: false
3
+
4
+ Metrics/LineLength:
5
+ Max: 100
6
+
7
+ Metrics/MethodLength:
8
+ Max: 15
9
+
10
+ Style/BlockDelimiters:
11
+ Enabled: false
12
+
13
+ Style/AsciiComments:
14
+ Enabled: false
15
+
16
+ Style/StructInheritance:
17
+ Enabled: false
18
+
19
+ Lint/HandleExceptions:
20
+ Enabled: false
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.13.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in activerecord_csv_importer.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,23 @@
1
+ group :red_green_refactor, halt_on_fail: true do
2
+ guard :rspec, cmd: 'rspec' do
3
+ require 'guard/rspec/dsl'
4
+ dsl = Guard::RSpec::Dsl.new(self)
5
+
6
+ # Feel free to open issues for suggestions and improvements
7
+
8
+ # RSpec files
9
+ rspec = dsl.rspec
10
+ watch(rspec.spec_helper) { rspec.spec_dir }
11
+ watch(rspec.spec_support) { rspec.spec_dir }
12
+ watch(rspec.spec_files)
13
+
14
+ # Ruby files
15
+ ruby = dsl.ruby
16
+ dsl.watch_spec_files_for(ruby.lib_files)
17
+ end
18
+
19
+ guard :rubocop, all_on_start: false, cmd: 'rubocop', cli: '--format fuubar' do
20
+ watch(/.+\.rb/)
21
+ watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) }
22
+ end
23
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Zulfiqar Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,339 @@
1
+ # ActiveRecordCSVImporter
2
+
3
+ Using bulk inserts, the goal for ActiveRecordCSVImporter is to speed up the processing of large CSV filed
4
+
5
+ ## Differences from CSVImporter
6
+
7
+ The key difference with ActiveRecordCSVImporter is the removal of callback support, since that is incompatible with bulk inserts.
8
+ Additional config options are available instead to deal with batching and duplicate index support.
9
+
10
+ It is compatible with ActiveRecord and any Databases supported by activerecord-import.
11
+
12
+ ## Usage tldr;
13
+
14
+ Define your CSVImporter:
15
+
16
+ ```ruby
17
+ class ImportUserCSV
18
+ include ActiveRecordCSVImporter
19
+
20
+ model User # an active record like model
21
+
22
+ column :email, to: ->(email) { email.downcase }, required: true
23
+ column :first_name, as: [ /first.?name/i, /pr(é|e)nom/i ]
24
+ column :last_name, as: [ /last.?name/i, "nom" ]
25
+
26
+ on_duplicate_key(
27
+ on_duplicate_key_update: {
28
+ conflict_target: [:email], columns: [:first_name, :last_name]
29
+ }
30
+ )
31
+
32
+ batch_size 500
33
+
34
+ each_batch { |report|
35
+ puts report.total_count
36
+ puts report.completed_count
37
+ puts report.failed_rows
38
+ }
39
+ end
40
+ ```
41
+
42
+ Run the import:
43
+
44
+ ```ruby
45
+ import = ImportUserCSV.new(file: my_file)
46
+
47
+ import.valid_header? # => false
48
+ import.report.message # => "The following columns are required: email"
49
+
50
+ # Assuming the header was valid, let's run the import!
51
+
52
+ import.run!
53
+ import.report.sucess? # => true
54
+ import.report.message # => "Import completed. 4 created, 2 updated, 1 failed to update"
55
+ ```
56
+
57
+ ## Installation
58
+
59
+ Add this line to your application's Gemfile:
60
+
61
+ ```ruby
62
+ gem 'activerecord_csv_importer'
63
+ ```
64
+
65
+ And then execute:
66
+
67
+ $ bundle
68
+
69
+ Or install it yourself as:
70
+
71
+ $ gem activerecord_csv_importer
72
+
73
+ ## Usage
74
+
75
+ ### Create an Importer
76
+
77
+ Create a class and include `CSVImporter`.
78
+
79
+ ```ruby
80
+ class ImportUserCSV
81
+ include CSVImporter
82
+ end
83
+ ```
84
+
85
+ ### Associate an active record model
86
+
87
+ The `model` is can be a active record model.
88
+
89
+ ```ruby
90
+ class ImportUserCSV
91
+ include CSVImporter
92
+
93
+ model User
94
+ end
95
+ ```
96
+
97
+ It can also be a relation which is handy to preset attributes:
98
+
99
+ ```ruby
100
+ class User
101
+ scope :pending, -> { where(status: 'pending') }
102
+ end
103
+
104
+ class ImportUserCSV
105
+ include CSVImporter
106
+
107
+ model User.pending
108
+ end
109
+ ```
110
+
111
+ You can change the configuration at runtime to scope down to associated
112
+ records.
113
+
114
+ ```ruby
115
+ class Team
116
+ has_many :users
117
+ end
118
+
119
+ team = Team.find(1)
120
+
121
+ ImportUserCSV.new(path: "tmp/my_file.csv") do
122
+ model team.users
123
+ end
124
+ ```
125
+
126
+
127
+ ### Define columns and their mapping
128
+
129
+ This is where the fun begins.
130
+
131
+ ```ruby
132
+ class ImportUserCSV
133
+ include CSVImporter
134
+
135
+ model User
136
+
137
+ column :email
138
+ end
139
+ ```
140
+
141
+ This will map the column named email to the email attribute. By default,
142
+ we downcase and strip the columns so it will work with a column spelled " EMail ".
143
+
144
+ Now, email could also be spelled "e-mail", or "mail", or even "courriel"
145
+ (oh, canada). Let's give it a couple of aliases then:
146
+
147
+
148
+ ```ruby
149
+ column :email, as: [/e.?mail/i, "courriel"]
150
+ ```
151
+
152
+ Nice, emails should be downcased though, so let's do this.
153
+
154
+ ```ruby
155
+ column :email, as: [/e.?mail/i, "courriel"], to: ->(email) { email.downcase }
156
+ ```
157
+
158
+ Now, what if the user does not provide the email column? It's not worth
159
+ running the import, we should just reject the CSV file right away.
160
+ That's easy:
161
+
162
+ ```ruby
163
+ class ImportUserCSV
164
+ include CSVImporter
165
+
166
+ model User
167
+
168
+ column :email, required: true
169
+ end
170
+
171
+ import = ImportUserCSV.new(content: "name\nbob")
172
+ import.valid_header? # => false
173
+ import.report.status # => :invalid_header
174
+ import.report.message # => "The following columns are required: 'email'"
175
+ ```
176
+
177
+
178
+ ### Upsert
179
+
180
+ You usually want to prevent duplicates when importing a CSV file. activerecord-import provides ON CONFLICT support for MySQL, SQLite (IGNORE only), and PostgreSQL. See the activerecord-import wiki for detailed syntax.
181
+
182
+ NOTE: If you have set up a unique index on a field and not set an appropriate ON CONFLICT resolution, activerecord_csv_import will raise an exception on the first duplicate insert.
183
+
184
+ ```ruby
185
+ class ImportUserCSV
186
+ include CSVImporter
187
+
188
+ model User
189
+
190
+ column :email, to: ->(email) { email.downcase }
191
+ column :first_name
192
+ column :last_name
193
+
194
+ on_duplicate_key(
195
+ on_duplicate_key_update: {
196
+ conflict_target: [:email], columns: [:first_name, :last_name]
197
+ }
198
+ )
199
+ end
200
+ ```
201
+
202
+ You are now done defining your importer, let's run it!
203
+
204
+ ### Import from a file, path or string
205
+
206
+ You can import from a file, path or just the CSV content. Please note
207
+ that we currently load the entire file in memory. Feel free to
208
+ contribute if you need to support CSV files with millions of lines! :)
209
+
210
+ ```ruby
211
+ import = ImportUserCSV.new(file: my_file)
212
+ import = ImportUserCSV.new(path: "tmp/new_users.csv")
213
+ import = ImportUserCSV.new(content: "email,name\nbob@example.com,bob")
214
+ ```
215
+
216
+ ### Overwrite configuration at runtime
217
+
218
+ It is often needed to change the configuration at runtime, that's quite
219
+ easy:
220
+
221
+ ```ruby
222
+ team = Team.find(1)
223
+ import = ImportUserCSV.new(file: my_file) do
224
+ model team.users
225
+ end
226
+ ```
227
+
228
+ ### `each_batch` callback
229
+
230
+ The number of rows to insert in the bulk query can be set by setting `batch_size` (default 500)
231
+
232
+ The each batch callback is triggered after each batch is processed and returns the report object for the full process. This is generally useful when you want to display progress.
233
+
234
+ ```ruby
235
+ progress_bar = ProgressBar.new
236
+
237
+ UserImport.new(file: my_file) do
238
+ each_batch do |report|
239
+ progress_bar.increment(report.progress_percentage)
240
+ end
241
+ end
242
+ ```
243
+ Other available methods are:
244
+ - total_count
245
+ - completed_count
246
+ - failed_rows
247
+
248
+ ### Validate the header
249
+
250
+ On a web application, as soon as a CSV file is uploaded, you can check
251
+ if it has the required columns. This is handy to fail early an provide
252
+ the user with a meaningful error message right away.
253
+
254
+ ```ruby
255
+ import = ImportUserCSV.new(file: params[:csv_file])
256
+ import.valid_header? # => false
257
+ import.report.message # => "The following columns are required: "email""
258
+ ```
259
+
260
+ ### Run the import and provide feedback to the user
261
+
262
+ ```ruby
263
+ import = ImportUserCSV.new(file: params[:csv_file])
264
+ import.run!
265
+ import.report.message # => "Import completed."
266
+ ```
267
+
268
+ You can get your hands dirty and fetch the errored rows and the
269
+ associated error message:
270
+
271
+ ```ruby
272
+ import.report.invalid_rows.map { |row| [row.model.email, row.errors] }
273
+ # => [ ['INVALID_EMAIL', 'first_name', 'last_name', { 'email' => 'is not an email' }] ]
274
+ ```
275
+
276
+ We do our best to map the errors back to the original column name. So
277
+ with the following definition:
278
+
279
+ ```ruby
280
+ column :email, as: /e.?mail/i
281
+ ```
282
+
283
+ and csv:
284
+
285
+ ```csv
286
+ E-Mail,name
287
+ INVALID_EMAIL,bob
288
+ ```
289
+
290
+ The error returned should be: `{ "E-Mail" => "is invalid" }`
291
+
292
+ ### Custom quote char
293
+
294
+ You can handle exotic quote chars with the `quote_char` option.
295
+
296
+ ```csv
297
+ email,name
298
+ bob@example.com,'bob "elvis" wilson'
299
+ ```
300
+
301
+ ```ruby
302
+ import = ImportUserCSV.new(content: csv_content)
303
+ import.run!
304
+ import.report.status
305
+ # => :invalid_csv_file
306
+ import.report.messages
307
+ # => CSV::MalformedCSVError: Illegal quoting in line 2.
308
+ ```
309
+
310
+ Let's provide a valid quote char:
311
+
312
+ ```ruby
313
+ import = ImportUserCSV.new(content: csv_content, quote_char: "'")
314
+ import.run!
315
+ # => [ ["bob@example.com", "bob \"elvis\" wilson"] ]
316
+ ```
317
+
318
+ ### Custom encoding
319
+
320
+ You can handle exotic encodings with the `encoding` option.
321
+
322
+ ```ruby
323
+ ImportUserCSV.new(content: "メール,氏名".encode('SJIS'), encoding: 'SJIS:UTF-8')
324
+ ```
325
+
326
+ ## Development
327
+
328
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment.
329
+
330
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
331
+
332
+ ## Contributing
333
+
334
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/activerecord_csv_importer.
335
+
336
+ ## License
337
+
338
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
339
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'activerecord_csv_importer/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'activerecord_csv_importer'
8
+ spec.version = ActiveRecordCSVImporter::VERSION
9
+ spec.authors = ['Zulfiqar Ali']
10
+ spec.email = ['desheikh@gmail.com']
11
+
12
+ spec.summary = 'A modified version of CSV Import using activerecord-import'
13
+ spec.homepage = 'https://github.com/desheikh/activerecord_csv_importer'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = 'exe'
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_dependency 'virtus', '~> 1.0'
24
+ spec.add_dependency 'activerecord-import', '~> 0.16'
25
+
26
+ spec.add_development_dependency 'bundler', '~> 1.13'
27
+ spec.add_development_dependency 'rake', '~> 10.5'
28
+
29
+ spec.add_development_dependency 'rspec', '~> 3.5'
30
+ spec.add_development_dependency 'rubocop', '~> 0.45'
31
+
32
+ spec.add_development_dependency 'guard-rspec', '~> 4.7'
33
+ spec.add_development_dependency 'guard-rubocop', '~> 1.2'
34
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'activerecord_csv_importer'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,10 @@
1
+ module ActiveRecordCSVImporter
2
+ # A Column from a CSV file with a `name` (from the csv file) and a matching
3
+ # `ColumnDefinition` if any.
4
+ class Column
5
+ include Virtus.model
6
+
7
+ attribute :name, String
8
+ attribute :definition, ColumnDefinition
9
+ end
10
+ end
@@ -0,0 +1,67 @@
1
+ module ActiveRecordCSVImporter
2
+ # Define a column. Called from the DSL via `column.
3
+ #
4
+ # Examples
5
+ #
6
+ # # the csv column "email" will be assigned to the `email` attribute
7
+ # column :email
8
+ #
9
+ # # the csv column matching /email/i will be assigned to the `email` attribute
10
+ # column :email, as: /email/i
11
+ #
12
+ # # the csv column matching "First name" or "Prénom" will be assigned to the
13
+ # `first_name` attribute
14
+ # column :first_name, as: [/first ?name/i, /pr(é|e)nom/i]
15
+ #
16
+ # # the csv column "first_name" will be assigned to the `f_name` attribute
17
+ # column :first_name, to: :f_name
18
+ #
19
+ # # email will be downcased
20
+ # column :email, to: ->(email) { email.downcase }
21
+ #
22
+ # # transform `confirmed` to `confirmed_at`
23
+ # column :confirmed, to: ->(confirmed, model) do
24
+ # model.confirmed_at = confirmed == "true" ? Time.new(2012) : nil
25
+ # end
26
+ #
27
+ class ColumnDefinition
28
+ include Virtus.model
29
+
30
+ attribute :name, Symbol
31
+ attribute :to # Symbol or Proc
32
+ attribute :as # Symbol, String, Regexp, Array
33
+ attribute :required, Boolean
34
+
35
+ # The model attribute that this column targets
36
+ def attribute
37
+ if to.is_a?(Symbol)
38
+ to
39
+ else
40
+ name
41
+ end
42
+ end
43
+
44
+ # rubocop:disable all
45
+ # Return true if column definition matches the column name passed in.
46
+ def match?(column_name, search_query = (as || name))
47
+ return false if column_name.nil?
48
+
49
+ downcased_column_name = column_name.downcase
50
+ underscored_column_name = downcased_column_name.gsub(/\s+/, '_')
51
+
52
+ case search_query
53
+ when Symbol
54
+ underscored_column_name == search_query.to_s
55
+ when String
56
+ downcased_column_name == search_query.downcase
57
+ when Regexp
58
+ column_name =~ search_query
59
+ when Array
60
+ search_query.any? { |query| match?(column_name, query) }
61
+ else
62
+ raise Error, "Invalid `as`. Should be a Symbol, String, Regexp or Array - was #{as.inspect}"
63
+ end
64
+ end
65
+ # rubocop:enable all
66
+ end
67
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveRecordCSVImporter
2
+ # The configuration of a ActiveRecordCSVImporter
3
+ class Config
4
+ include Virtus.model
5
+
6
+ attribute :model
7
+ attribute :column_definitions, Array[ColumnDefinition], default: proc { [] }
8
+ attribute :on_duplicate_key, Hash, default: []
9
+ attribute :batch_size, Integer, default: 500
10
+ attribute :each_batch_blocks, Array[Proc], default: []
11
+
12
+ def each_batch(block)
13
+ each_batch_blocks << block
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,74 @@
1
+ module ActiveRecordCSVImporter
2
+ # Reads, sanitize and parse a CSV file
3
+ class CSVReader
4
+ include Virtus.model
5
+
6
+ attribute :content, String
7
+ attribute :file # IO
8
+ attribute :path, String
9
+ attribute :quote_char, String, default: '"'
10
+ attribute :encoding, String, default: 'UTF-8:UTF-8'
11
+
12
+ def csv_rows
13
+ @csv_rows ||= begin
14
+ sane_content = sanitize_content(read_content)
15
+ separator = detect_separator(sane_content)
16
+ cells = CSV.parse(
17
+ sane_content,
18
+ col_sep: separator,
19
+ quote_char: quote_char,
20
+ skip_blanks: true,
21
+ encoding: encoding
22
+ )
23
+ sanitize_cells(cells)
24
+ end
25
+ end
26
+
27
+ # Returns the header as an Array of Strings
28
+ def header
29
+ @header ||= csv_rows.first
30
+ end
31
+
32
+ # Returns the rows as an Array of Arrays of Strings
33
+ def rows
34
+ @rows ||= csv_rows[1..-1]
35
+ end
36
+
37
+ private
38
+
39
+ def read_content
40
+ if content
41
+ content
42
+ elsif file
43
+ file.read
44
+ elsif path
45
+ File.open(path).read
46
+ else
47
+ raise Error, 'Please provide content, file, or path'
48
+ end
49
+ end
50
+
51
+ def sanitize_content(csv_content)
52
+ internal_encoding = encoding.split(':').last
53
+ # Remove invalid byte sequences
54
+ csv_content
55
+ .encode(Encoding.find(internal_encoding), invalid: :replace, undef: :replace, replace: '')
56
+ .gsub(/\r\r?\n?/, "\n") # Replaces windows line separators with "\n"
57
+ end
58
+
59
+ SEPARATORS = [',', ';', "\t"].freeze
60
+
61
+ def detect_separator(csv_content)
62
+ SEPARATORS.sort_by { |separator| csv_content.count(separator) }.last
63
+ end
64
+
65
+ # Remove trailing white spaces and ensure we always return a string
66
+ def sanitize_cells(rows)
67
+ rows.map do |cells|
68
+ cells.map do |cell|
69
+ cell ? cell.strip : ''
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,25 @@
1
+ module ActiveRecordCSVImporter
2
+ # This Dsl extends a class that includes ActiveRecordCSVImporter
3
+ # It is a thin proxy to the Config object
4
+ module Dsl
5
+ def model(model_klass)
6
+ config.model = model_klass
7
+ end
8
+
9
+ def column(name, options = {})
10
+ config.column_definitions << options.merge(name: name)
11
+ end
12
+
13
+ def on_duplicate_key(options)
14
+ config.on_duplicate_key = options
15
+ end
16
+
17
+ def batch_size(size)
18
+ config.batch_size = size
19
+ end
20
+
21
+ def each_batch(&block)
22
+ config.each_batch(block)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ module ActiveRecordCSVImporter
2
+ # The CSV Header
3
+ class Header
4
+ include Virtus.model
5
+
6
+ attribute :column_definitions, Array[ColumnDefinition]
7
+ attribute :column_names, Array[String]
8
+
9
+ def columns
10
+ column_names.map do |column_name|
11
+ Column.new(
12
+ name: column_name,
13
+ definition: find_column_definition(column_name)
14
+ )
15
+ end
16
+ end
17
+
18
+ def column_name_for_model_attribute(attribute)
19
+ column = columns.find { |c|
20
+ c.definition.attribute == attribute if c.definition
21
+ }
22
+ column.name if column
23
+ end
24
+
25
+ def valid?
26
+ missing_required_columns.empty?
27
+ end
28
+
29
+ # Returns Array[String]
30
+ def required_columns
31
+ column_definitions.select(&:required?).map(&:name)
32
+ end
33
+
34
+ # Returns Array[String]
35
+ def missing_required_columns
36
+ (column_definitions.select(&:required?) - columns.map(&:definition))
37
+ .map(&:name).map(&:to_s)
38
+ end
39
+
40
+ # Returns Array[String]
41
+ def missing_columns
42
+ (column_definitions - columns.map(&:definition)).map(&:name).map(&:to_s)
43
+ end
44
+
45
+ private
46
+
47
+ def find_column_definition(name)
48
+ column_definitions.find { |column_definition|
49
+ column_definition.match?(name)
50
+ }
51
+ end
52
+
53
+ def column_definition_names
54
+ column_definitions.map(&:name).map(&:to_s)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,92 @@
1
+ module ActiveRecordCSVImporter
2
+ # The Report you get back from an import.
3
+ #
4
+ # * It has a status (pending, invalid_csv_file, invalid_header, in_progress, done, aborted)
5
+ # * It lists out missing columns
6
+ # * It reports parser_error
7
+ # * It lists out (created / updated) * (success / failed) records
8
+ # * It provides a human readable message
9
+ #
10
+ class Report
11
+ include Virtus.model
12
+
13
+ attribute :status, Symbol, default: proc { :pending }
14
+
15
+ attribute :missing_columns, Array[Symbol], default: proc { [] }
16
+
17
+ attribute :parser_error, String
18
+
19
+ attribute :total_count, Integer, default: 0
20
+ attribute :completed_count, Integer, default: 0
21
+ attribute :invalid_rows, Array[Array], default: {}
22
+
23
+ attribute :message_generator, Class, default: proc { ReportMessage }
24
+
25
+ def progress_percentage
26
+ return 0 if total_count.zero?
27
+ (completed_count.to_f / total_count * 100).round
28
+ end
29
+
30
+ def success?
31
+ done? && invalid_rows.empty?
32
+ end
33
+
34
+ def pending?
35
+ status == :pending
36
+ end
37
+
38
+ def in_progress?
39
+ status == :in_progress
40
+ end
41
+
42
+ def done?
43
+ status == :done
44
+ end
45
+
46
+ def aborted?
47
+ status == :aborted
48
+ end
49
+
50
+ def invalid_header?
51
+ status == :invalid_header
52
+ end
53
+
54
+ def invalid_csv_file?
55
+ status == :invalid_csv_file
56
+ end
57
+
58
+ def pending!
59
+ self.status = :pending
60
+ self
61
+ end
62
+
63
+ def in_progress!
64
+ self.status = :in_progress
65
+ self
66
+ end
67
+
68
+ def done!
69
+ self.status = :done
70
+ self
71
+ end
72
+
73
+ def aborted!
74
+ self.status = :aborted
75
+ self
76
+ end
77
+
78
+ def invalid_header!
79
+ self.status = :invalid_header
80
+ self
81
+ end
82
+
83
+ def invalid_csv_file!
84
+ self.status = :invalid_csv_file
85
+ self
86
+ end
87
+
88
+ def message
89
+ message_generator.call(self)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,44 @@
1
+ module ActiveRecordCSVImporter
2
+ # Generate a human readable message for the given report.
3
+ class ReportMessage
4
+ def self.call(report)
5
+ new(report).to_s
6
+ end
7
+
8
+ def initialize(report)
9
+ @report = report
10
+ end
11
+
12
+ attr_accessor :report
13
+
14
+ def to_s
15
+ send("report_#{report.status}")
16
+ end
17
+
18
+ private
19
+
20
+ def report_pending
21
+ "Import hasn't started yet"
22
+ end
23
+
24
+ def report_in_progress
25
+ 'Import in progress'
26
+ end
27
+
28
+ def report_done
29
+ 'Import completed'
30
+ end
31
+
32
+ def report_invalid_header
33
+ "The following columns are required: #{report.missing_columns.join(', ')}"
34
+ end
35
+
36
+ def report_invalid_csv_file
37
+ report.parser_error
38
+ end
39
+
40
+ def report_aborted
41
+ 'Import aborted'
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ module ActiveRecordCSVImporter
2
+ # A Row from the CSV file.
3
+ #
4
+ # returns a formatted version of the row based of the to proc
5
+ class Row
6
+ include Virtus.model
7
+
8
+ attribute :header, Header
9
+ attribute :row_array, Array[String]
10
+
11
+ # A hash with this row's attributes
12
+ def csv_attributes
13
+ @csv_attributes ||= Hash[header.column_names.zip(row_array)]
14
+ end
15
+
16
+ def to_a
17
+ header.columns.each_with_object([]) do |column, memo|
18
+ value = csv_attributes[column.name]
19
+ begin
20
+ value = value.dup if value
21
+ rescue TypeError
22
+ # can't dup Symbols, Integer etc...
23
+ end
24
+
25
+ next if column.definition.nil?
26
+
27
+ memo << get_attribute(column.definition, value)
28
+ end
29
+ end
30
+
31
+ # Set the attribute using the column_definition and the csv_value
32
+ def get_attribute(column_definition, csv_value)
33
+ if column_definition.to && column_definition.to.is_a?(Proc)
34
+ column_definition.to.call(csv_value)
35
+ else
36
+ column_definition.attribute
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,66 @@
1
+ module ActiveRecordCSVImporter
2
+ # Do the actual import.
3
+ #
4
+ # It iterates over the rows' models and persist them. It returns a `Report`.
5
+ class Runner
6
+ def self.call(*args)
7
+ new(*args).call
8
+ end
9
+
10
+ include Virtus.model
11
+
12
+ attribute :model
13
+ attribute :header
14
+ attribute :rows, Array[CSV::Row]
15
+ attribute :config, Object
16
+
17
+ attribute :report, Report, default: proc { Report.new }
18
+
19
+ # Persist the csv rows and return a Report
20
+ def call
21
+ report.total_count = rows.count
22
+ report.in_progress!
23
+ persist_rows!
24
+ report.done!
25
+
26
+ report
27
+ end
28
+
29
+ private
30
+
31
+ def persist_rows!
32
+ rows.in_groups_of(config.batch_size, false) do |set|
33
+ response = import_rows(set)
34
+
35
+ add_to_report(response, set)
36
+ config.each_batch_blocks.each { |block| block.call(report) }
37
+ end
38
+ end
39
+
40
+ def import_rows(set)
41
+ config.model.import(header.column_names.dup, set, config.on_duplicate_key)
42
+ end
43
+
44
+ def add_to_report(response, set)
45
+ report.completed_count += set.length
46
+
47
+ response.failed_instances.each do |model|
48
+ report.invalid_rows << (columns_for(model) << build_error(model))
49
+ end
50
+ end
51
+
52
+ def columns_for(model)
53
+ model.attributes.values_at(*header.column_names)
54
+ end
55
+
56
+ # Error from the model mapped back to the CSV header if we can
57
+ def build_error(model)
58
+ Hash[
59
+ model.errors.map do |attribute, errors|
60
+ column_name = header.column_name_for_model_attribute(attribute)
61
+ column_name ? [column_name, errors] : [attribute, errors]
62
+ end
63
+ ]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveRecordCSVImporter
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,90 @@
1
+ require 'csv'
2
+ require 'virtus'
3
+
4
+ require 'activerecord_csv_importer/version'
5
+ require 'activerecord_csv_importer/csv_reader'
6
+ require 'activerecord_csv_importer/column_definition'
7
+ require 'activerecord_csv_importer/column'
8
+ require 'activerecord_csv_importer/header'
9
+ require 'activerecord_csv_importer/row'
10
+ require 'activerecord_csv_importer/report'
11
+ require 'activerecord_csv_importer/report_message'
12
+ require 'activerecord_csv_importer/runner'
13
+ require 'activerecord_csv_importer/config'
14
+ require 'activerecord_csv_importer/dsl'
15
+
16
+ module ActiveRecordCSVImporter
17
+ class Error < StandardError; end
18
+
19
+ # Setup DSL and config object
20
+ def self.included(klass)
21
+ klass.extend(Dsl)
22
+ klass.define_singleton_method(:config) do
23
+ @config ||= Config.new
24
+ end
25
+ end
26
+
27
+ # Instance level config will run against this configurator
28
+ class Configurator < Struct.new(:config)
29
+ include Dsl
30
+ end
31
+
32
+ # Defines the path, file or content of the csv file.
33
+ # Also allows you to overwrite the configuration at runtime.
34
+ #
35
+ # Example:
36
+ #
37
+ # .new(file: my_csv_file)
38
+ # .new(path: "subscribers.csv", model: newsletter.subscribers)
39
+ #
40
+ def initialize(*args, &block)
41
+ @csv = CSVReader.new(*args)
42
+ @config = self.class.config.dup
43
+ @config.attributes = args.last
44
+ @report = Report.new
45
+ Configurator.new(@config).instance_exec(&block) if block
46
+ end
47
+
48
+ attr_reader :csv, :report, :config
49
+
50
+ # Initialize and return the `Header` for the current CSV file
51
+ def header
52
+ @header ||= Header.new(
53
+ column_definitions: config.column_definitions,
54
+ column_names: csv.header
55
+ )
56
+ end
57
+
58
+ # Initialize and return the `Row`s for the current CSV file
59
+ def rows
60
+ csv.rows.map { |row_array|
61
+ Row.new(header: header, row_array: row_array).to_a
62
+ }
63
+ end
64
+
65
+ def valid_header?
66
+ if @report.pending?
67
+ if header.valid?
68
+ @report = Report.new(status: :pending)
69
+ else
70
+ @report = Report.new(
71
+ status: :invalid_header,
72
+ missing_columns: header.missing_required_columns
73
+ )
74
+ end
75
+ end
76
+
77
+ header.valid?
78
+ end
79
+
80
+ # Run the import. Return a Report.
81
+ def run!
82
+ if valid_header?
83
+ @report = Runner.call(header: header, rows: rows, config: config)
84
+ else
85
+ @report
86
+ end
87
+ rescue CSV::MalformedCSVError => e
88
+ @report = Report.new(status: :invalid_csv_file, parser_error: e.message)
89
+ end
90
+ end
metadata ADDED
@@ -0,0 +1,180 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord_csv_importer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Zulfiqar Ali
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-11-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: virtus
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord-import
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.16'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.16'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.5'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.5'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.5'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.45'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.45'
97
+ - !ruby/object:Gem::Dependency
98
+ name: guard-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '4.7'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '4.7'
111
+ - !ruby/object:Gem::Dependency
112
+ name: guard-rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.2'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.2'
125
+ description:
126
+ email:
127
+ - desheikh@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".gitignore"
133
+ - ".rspec"
134
+ - ".rubocop.yml"
135
+ - ".travis.yml"
136
+ - Gemfile
137
+ - Guardfile
138
+ - LICENSE.txt
139
+ - README.md
140
+ - Rakefile
141
+ - activerecord_csv_importer.gemspec
142
+ - bin/console
143
+ - bin/setup
144
+ - lib/activerecord_csv_importer.rb
145
+ - lib/activerecord_csv_importer/column.rb
146
+ - lib/activerecord_csv_importer/column_definition.rb
147
+ - lib/activerecord_csv_importer/config.rb
148
+ - lib/activerecord_csv_importer/csv_reader.rb
149
+ - lib/activerecord_csv_importer/dsl.rb
150
+ - lib/activerecord_csv_importer/header.rb
151
+ - lib/activerecord_csv_importer/report.rb
152
+ - lib/activerecord_csv_importer/report_message.rb
153
+ - lib/activerecord_csv_importer/row.rb
154
+ - lib/activerecord_csv_importer/runner.rb
155
+ - lib/activerecord_csv_importer/version.rb
156
+ homepage: https://github.com/desheikh/activerecord_csv_importer
157
+ licenses:
158
+ - MIT
159
+ metadata: {}
160
+ post_install_message:
161
+ rdoc_options: []
162
+ require_paths:
163
+ - lib
164
+ required_ruby_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ required_rubygems_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ requirements: []
175
+ rubyforge_project:
176
+ rubygems_version: 2.5.1
177
+ signing_key:
178
+ specification_version: 4
179
+ summary: A modified version of CSV Import using activerecord-import
180
+ test_files: []