gunwale 0.5.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/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # RowBoat
2
+
3
+ Created by &nbsp;&nbsp;&nbsp; [<img src="https://raw.githubusercontent.com/devmynd/row_boat/master/devmynd-logo.png" alt="DevMynd Logo" />](https://www.devmynd.com/)
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/row_boat.svg)](http://badge.fury.io/rb/row_boat) &nbsp;&nbsp;&nbsp;[![Build Status](https://travis-ci.org/devmynd/row_boat.svg?branch=master)](https://travis-ci.org/devmynd/row_boat)
6
+
7
+ A simple gem to help you import CSVs into your ActiveRecord models.
8
+
9
+ [Check out the documentation!](/API.md#rowboat-api)
10
+
11
+ It uses [SmarterCSV](https://github.com/tilo/smarter_csv) and [`activerecord-import`](https://github.com/zdennis/activerecord-import) to import database records from your CSVs.
12
+
13
+ ## Contents
14
+
15
+ - [Installation](#installation)
16
+ - [Basic Usage](#basic-usage)
17
+ - [Development](#development)
18
+ - [Contributing](#contributing)
19
+ - [License](#license)
20
+
21
+ ## Installation
22
+
23
+ Add this line to your application's Gemfile:
24
+
25
+ ```ruby
26
+ gem "row_boat", "~> 0.4"
27
+ ```
28
+
29
+ And then execute:
30
+
31
+ $ bundle
32
+
33
+ ## Basic Usage
34
+
35
+ #### [Full documentation can be found here.](/API.md#rowboat-api)
36
+
37
+ Below we're defining the required methods ([`import_into`](/API.md#import_into) and [`column_mapping`](/API.md#column_mapping)) and a few additional options as well (via [`value_converters`](/API.md#value_converters) and [`options`](/API.md#options)). Checkout [API.md](/API.md#rowboat-api) for the full documentation for more details :)
38
+
39
+ ```ruby
40
+ class ImportProduct < RowBoat::Base
41
+ # required
42
+ def import_into
43
+ Product # The ActiveRecord class we want to import records into.
44
+ end
45
+
46
+ # required
47
+ def column_mapping
48
+ {
49
+ # `:prdct_name` is the downcased and symbolized version
50
+ # of our column header, while `:name` is the attribute
51
+ # of our model we want to receive `:prdct_name`'s value
52
+ prdct_name: :name,
53
+ dllr_amnt: :price_in_cents,
54
+ desc: :description
55
+ }
56
+ end
57
+
58
+ # optional
59
+ def value_converters
60
+ {
61
+ # Allows us to change values we want to import
62
+ # before we import them
63
+ price_in_cents: -> (value) { value * 1000 }
64
+ }
65
+ end
66
+
67
+ # optional
68
+ def preprocess_row(row)
69
+ if row[:name] && row[:description] && row[:price]
70
+ row
71
+ else
72
+ nil # return nil to skip a row
73
+ end
74
+ # we could also remove some attributes or do any
75
+ # other kind of work we want with the given row.
76
+ end
77
+
78
+ #optional
79
+ def options
80
+ {
81
+ # These are additional configurations that
82
+ # are generally passed through to SmarterCSV
83
+ # and activerecord-import
84
+ validate: false, # this defaults to `true`
85
+ wrap_in_transaction: false # this defaults to `true`
86
+ }
87
+ end
88
+ end
89
+ ```
90
+
91
+ ## Development
92
+
93
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests (run `appraisal install` and `appraisal rake` to run the tests against different combinations of dependencies). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
94
+
95
+ ## Contributing
96
+
97
+ Bug reports and pull requests are welcome on GitHub at https://github.com/devmynd/row_boat. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
98
+
99
+ ## License
100
+
101
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
102
+
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+ require "standalone_migrations"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+ StandaloneMigrations::Tasks.load_tasks
11
+
12
+ task default: %i[rubocop spec]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "row_boat"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,10 @@
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
9
+ bundle exec rake db:create
10
+ bundle exec rake db:migrate
data/db/config.yml ADDED
@@ -0,0 +1,12 @@
1
+ default: &default
2
+ adapter: postgresql
3
+ database: row_boat
4
+ username: postgres
5
+ password: postgres
6
+ host: localhost
7
+
8
+ development:
9
+ <<: *default
10
+
11
+ test:
12
+ <<: *default
@@ -0,0 +1,13 @@
1
+ class CreateProducts < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :products do |t|
4
+ t.string :name, null: false
5
+ t.integer :rank, null: false
6
+ t.text :description
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :products, :rank, unique: true
12
+ end
13
+ end
data/db/schema.rb ADDED
@@ -0,0 +1,26 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[7.1].define(version: 2017_05_11_160154) do
14
+ # These are extensions that must be enabled in order to support this database
15
+ enable_extension "plpgsql"
16
+
17
+ create_table "products", force: :cascade do |t|
18
+ t.string "name", null: false
19
+ t.integer "rank", null: false
20
+ t.text "description"
21
+ t.datetime "created_at", precision: nil, null: false
22
+ t.datetime "updated_at", precision: nil, null: false
23
+ t.index ["rank"], name: "index_products_on_rank", unique: true
24
+ end
25
+
26
+ end
data/devmynd-logo.png ADDED
Binary file
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord-import", "~> 0.18.2"
6
+ gem "smarter_csv", "~> 1.1.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,144 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ row_boat (0.5.0)
5
+ activerecord (>= 5.0.0)
6
+ activerecord-import (~> 0.18.2)
7
+ smarter_csv (~> 1.1)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actionpack (5.1.0)
13
+ actionview (= 5.1.0)
14
+ activesupport (= 5.1.0)
15
+ rack (~> 2.0)
16
+ rack-test (~> 0.6.3)
17
+ rails-dom-testing (~> 2.0)
18
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
19
+ actionview (5.1.0)
20
+ activesupport (= 5.1.0)
21
+ builder (~> 3.1)
22
+ erubi (~> 1.4)
23
+ rails-dom-testing (~> 2.0)
24
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
25
+ activemodel (5.1.0)
26
+ activesupport (= 5.1.0)
27
+ activerecord (5.1.0)
28
+ activemodel (= 5.1.0)
29
+ activesupport (= 5.1.0)
30
+ arel (~> 8.0)
31
+ activerecord-import (0.18.3)
32
+ activerecord (>= 3.2)
33
+ activesupport (5.1.0)
34
+ concurrent-ruby (~> 1.0, >= 1.0.2)
35
+ i18n (~> 0.7)
36
+ minitest (~> 5.1)
37
+ tzinfo (~> 1.1)
38
+ appraisal (2.2.0)
39
+ bundler
40
+ rake
41
+ thor (>= 0.14.0)
42
+ arel (8.0.0)
43
+ ast (2.3.0)
44
+ awesome_print (1.7.0)
45
+ builder (3.2.3)
46
+ coderay (1.1.1)
47
+ concurrent-ruby (1.0.5)
48
+ database_cleaner (1.6.1)
49
+ diff-lcs (1.3)
50
+ erubi (1.6.0)
51
+ i18n (0.8.1)
52
+ loofah (2.0.3)
53
+ nokogiri (>= 1.5.9)
54
+ method_source (0.8.2)
55
+ mini_portile2 (2.1.0)
56
+ minitest (5.10.2)
57
+ nokogiri (1.7.2)
58
+ mini_portile2 (~> 2.1.0)
59
+ parser (2.4.0.0)
60
+ ast (~> 2.2)
61
+ pg (0.20.0)
62
+ powerpack (0.1.1)
63
+ pry (0.10.4)
64
+ coderay (~> 1.1.0)
65
+ method_source (~> 0.8.1)
66
+ slop (~> 3.4)
67
+ pry-doc (0.10.0)
68
+ pry (~> 0.9)
69
+ yard (~> 0.9)
70
+ pry-nav (0.2.4)
71
+ pry (>= 0.9.10, < 0.11.0)
72
+ rack (2.0.2)
73
+ rack-test (0.6.3)
74
+ rack (>= 1.0)
75
+ rails-dom-testing (2.0.3)
76
+ activesupport (>= 4.2.0)
77
+ nokogiri (>= 1.6)
78
+ rails-html-sanitizer (1.0.3)
79
+ loofah (~> 2.0)
80
+ railties (5.1.0)
81
+ actionpack (= 5.1.0)
82
+ activesupport (= 5.1.0)
83
+ method_source
84
+ rake (>= 0.8.7)
85
+ thor (>= 0.18.1, < 2.0)
86
+ rainbow (2.2.2)
87
+ rake
88
+ rake (10.5.0)
89
+ rspec (3.6.0)
90
+ rspec-core (~> 3.6.0)
91
+ rspec-expectations (~> 3.6.0)
92
+ rspec-mocks (~> 3.6.0)
93
+ rspec-core (3.6.0)
94
+ rspec-support (~> 3.6.0)
95
+ rspec-expectations (3.6.0)
96
+ diff-lcs (>= 1.2.0, < 2.0)
97
+ rspec-support (~> 3.6.0)
98
+ rspec-mocks (3.6.0)
99
+ diff-lcs (>= 1.2.0, < 2.0)
100
+ rspec-support (~> 3.6.0)
101
+ rspec-support (3.6.0)
102
+ rubocop (0.48.1)
103
+ parser (>= 2.3.3.1, < 3.0)
104
+ powerpack (~> 0.1)
105
+ rainbow (>= 1.99.1, < 3.0)
106
+ ruby-progressbar (~> 1.7)
107
+ unicode-display_width (~> 1.0, >= 1.0.1)
108
+ ruby-progressbar (1.8.1)
109
+ slop (3.6.0)
110
+ smarter_csv (1.1.4)
111
+ standalone_migrations (5.2.1)
112
+ activerecord (>= 4.2.7, < 5.2.0)
113
+ railties (>= 4.2.7, < 5.2.0)
114
+ rake (~> 10.0)
115
+ thor (0.19.4)
116
+ thread_safe (0.3.6)
117
+ tzinfo (1.2.3)
118
+ thread_safe (~> 0.1)
119
+ unicode-display_width (1.2.1)
120
+ yard (0.9.9)
121
+
122
+ PLATFORMS
123
+ ruby
124
+
125
+ DEPENDENCIES
126
+ activerecord-import (~> 0.18.2)
127
+ appraisal (~> 2.2.0)
128
+ awesome_print
129
+ bundler (~> 1.14)
130
+ database_cleaner (~> 1.6.0)
131
+ pg
132
+ pry
133
+ pry-doc
134
+ pry-nav
135
+ rake (~> 10.0)
136
+ row_boat!
137
+ rspec (~> 3.0)
138
+ rubocop (~> 0.48.1)
139
+ smarter_csv (~> 1.1.0)
140
+ standalone_migrations (~> 5.2.0)
141
+ yard (~> 0.9.9)
142
+
143
+ BUNDLED WITH
144
+ 1.15.1
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "activerecord-import"
5
+ require "smarter_csv"
6
+
7
+ module RowBoat
8
+ class Base
9
+ InvalidColumnMapping = Class.new(StandardError)
10
+
11
+ attr_reader :csv_source
12
+
13
+ class << self
14
+ # Imports database records from the given CSV-like object.
15
+ #
16
+ # @overload import(csv_source)
17
+ # @param csv_source [String, #read] a CSV-like object that SmarterCSV can read.
18
+ #
19
+ # @return [Hash] a hash with +:invalid_records+, +:total_inserts+, +:inserted_ids+, and +:skipped_rows+.
20
+ #
21
+ # @see https://github.com/tilo/smarter_csv#documentation SmarterCSV Docs
22
+ def import(*args, &block)
23
+ new(*args, &block).import
24
+ end
25
+ end
26
+
27
+ # Makes a new instance with the given +csv_source+.
28
+ #
29
+ # @abstract Override this method if you need additional arguments to process your CSV (like defaults).
30
+ #
31
+ # @example
32
+ # def initialize(csv_source, default_name)
33
+ # super(csv_source)
34
+ # @default_name = default_name
35
+ # end
36
+ def initialize(csv_source)
37
+ @csv_source = csv_source
38
+ end
39
+
40
+ # Parses the csv and inserts/updates the database. You probably won't call this method directly,
41
+ # instead you would call {RowBoat::Base.import}.
42
+ #
43
+ # @return [Hash] a hash with +:invalid_records+, +:total_inserts+, +:inserted_ids+, and +:skipped_rows+.
44
+ def import
45
+ import_results = []
46
+
47
+ transaction_if_needed do
48
+ parse_rows do |rows|
49
+ import_results << import_rows(rows)
50
+ end
51
+ end
52
+
53
+ process_import_results(import_results).tap do |total_results|
54
+ handle_failed_rows(total_results[:invalid_records])
55
+ end
56
+ end
57
+
58
+ # Override with the ActiveRecord class that the CSV should be imported into.
59
+ #
60
+ # @abstract
61
+ #
62
+ # @note You must implement this method.
63
+ #
64
+ # @example
65
+ # def import_into
66
+ # Product
67
+ # end
68
+ def import_into
69
+ raise NotImplementedError, not_implemented_error_message(__method__)
70
+ end
71
+
72
+ # Override with a hash that maps CSV column names to their preferred names.
73
+ # Oftentimes these are the names of the attributes on the model class from {#import_into}.
74
+ #
75
+ # @abstract
76
+ #
77
+ # @note You must implement this method.
78
+ #
79
+ # @example
80
+ # def column_mapping
81
+ # {
82
+ # prdct_name: :name,
83
+ # price: :price,
84
+ # sl_exp: :sale_expires_at
85
+ # }
86
+ # end
87
+ #
88
+ # @see #import_into
89
+ def column_mapping
90
+ raise NotImplementedError, not_implemented_error_message(__method__)
91
+ end
92
+
93
+ # Override this method if you need to do some work on the row before the record is
94
+ # inserted/updated or want to skip the row in the import. Simply return +nil+ to skip the row.
95
+ #
96
+ # @abstract
97
+ #
98
+ # @note If you only need to manipulate one attribute (ie parse a date from a string, etc), then
99
+ # you should probably use {#value_converters}
100
+ #
101
+ # @return [Hash,NilClass] a hash of attributes, +nil+, or even and instance of the class returned
102
+ # in {#import_into}.
103
+ #
104
+ # @see #import_into
105
+ def preprocess_row(row)
106
+ row
107
+ end
108
+
109
+ # @api private
110
+ def import_rows(rows)
111
+ import_options = ::RowBoat::Helpers.extract_import_options(merged_options)
112
+ preprocessed_rows = preprocess_rows(rows)
113
+ import_into.import(preprocessed_rows, import_options)
114
+ end
115
+
116
+ # Override this method if you need to do something with a chunk of rows.
117
+ #
118
+ # @abstract
119
+ #
120
+ # @note If you want to filter out a row, you can just return +nil+ from {#preprocess_row}.
121
+ #
122
+ # @see #preprocess_row
123
+ def preprocess_rows(rows)
124
+ rows.each_with_object([]) do |row, preprocessed_rows|
125
+ increment_row_number
126
+ preprocessed_row = preprocess_row(row)
127
+ preprocessed_row ? preprocessed_rows << preprocessed_row : add_skipped_row(row)
128
+ end
129
+ end
130
+
131
+ # Override this method to specify CSV parsing and importing options.
132
+ # All SmarterCSV and activerecord-import options can be listed here along with
133
+ # +:wrap_in_transaction+. The defaults provided by RowBoat can be found in {#default_options}
134
+ #
135
+ # @abstract
136
+ #
137
+ # @note If you want to use the +:value_converters+ option provided by SmarterCSV
138
+ # just override {#value_converters}.
139
+ #
140
+ # @return [Hash] a hash of configuration options.
141
+ #
142
+ # @see https://github.com/tilo/smarter_csv#documentation SmarterCSV docs
143
+ # @see https://github.com/zdennis/activerecord-import/wiki activerecord-import docs
144
+ # @see #value_converters
145
+ def options
146
+ {}
147
+ end
148
+
149
+ # Default options provided by RowBoat for CSV parsing and importing.
150
+ #
151
+ # @note Do not override.
152
+ #
153
+ # @return [Hash] a hash of configuration options.
154
+ #
155
+ # @api private
156
+ def default_options
157
+ {
158
+ chunk_size: 500,
159
+ recursive: false,
160
+ validate: true,
161
+ value_converters: csv_value_converters,
162
+ wrap_in_transaction: true
163
+ }.merge(column_mapping_options)
164
+ end
165
+
166
+ # @api private
167
+ def merged_options
168
+ default_options.merge(options)
169
+ end
170
+
171
+ # Override this method to do some work with a row that has failed to import.
172
+ #
173
+ # @abstract
174
+ #
175
+ # @note +row+ here is actually an instance of the class returned in {#import_into}
176
+ #
177
+ # @see #import_into
178
+ def handle_failed_row(row)
179
+ row
180
+ end
181
+
182
+ # Override this method to do some work will all of the rows that failed to import.
183
+ #
184
+ # @abstract
185
+ #
186
+ # @note If you override this method and {#handle_failed_row}, be sure to call +super+.
187
+ def handle_failed_rows(rows)
188
+ rows.each { |row| handle_failed_row(row) }
189
+ end
190
+
191
+ # Override this method to specify how to translate values from the CSV
192
+ # into ruby objects.
193
+ #
194
+ # You can provide an object that implements +convert+, a proc or lambda, or the
195
+ # the name of a method as a Symbol
196
+ #
197
+ # @abstract
198
+ #
199
+ # @example
200
+ # def value_converters
201
+ # {
202
+ # name: -> (value) { value.titleize }
203
+ # price: :convert_price,
204
+ # expires_at: CustomDateConverter
205
+ # }
206
+ # end
207
+ #
208
+ # def convert_price(value)
209
+ # value || 0
210
+ # end
211
+ def value_converters
212
+ {}
213
+ end
214
+
215
+ # @api private
216
+ def csv_value_converters
217
+ value_converters.each_with_object({}) do |(key, potential_converter), converters_hash|
218
+ case potential_converter
219
+ when Proc
220
+ converters_hash[key] = ::RowBoat::ValueConverter.new(&potential_converter)
221
+ when Symbol
222
+ converters_hash[key] = ::RowBoat::ValueConverter.new { |value| public_send(potential_converter, value) }
223
+ when nil
224
+ next
225
+ else
226
+ converters_hash[key] = potential_converter
227
+ end
228
+ end
229
+ end
230
+
231
+ # Implement this method if you'd like to rollback the transaction
232
+ # after it otherwise has completed.
233
+ #
234
+ # @abstract
235
+ #
236
+ # @note Only works if the `wrap_in_transaction` option is `true`
237
+ # (which is the default)
238
+ #
239
+ # @return [Boolean]
240
+ def rollback_transaction?
241
+ false
242
+ end
243
+
244
+ private
245
+
246
+ # @private
247
+ attr_reader :row_number
248
+
249
+ # @api private
250
+ # @private
251
+ attr_reader :skipped_rows
252
+
253
+ # @api private
254
+ # @private
255
+ def increment_row_number
256
+ @row_number = row_number.to_i + 1
257
+ end
258
+
259
+ def add_skipped_row(row)
260
+ @skipped_rows ||= []
261
+ skipped_rows << row
262
+ end
263
+
264
+ # @api private
265
+ # @private
266
+ def column_mapping_options
267
+ case column_mapping
268
+ when Hash
269
+ { key_mapping: column_mapping, remove_unmapped_keys: true }
270
+ when Array
271
+ { user_provided_headers: column_mapping }
272
+ else
273
+ raise InvalidColumnMapping, "#column_mapping must be a Hash or an Array: got `#{column_mapping}`"
274
+ end
275
+ end
276
+
277
+ # @api private
278
+ # @private
279
+ def not_implemented_error_message(method_name)
280
+ "Subclasses of #{self.class.name} must implement `#{method_name}`"
281
+ end
282
+
283
+ # @api private
284
+ # @private
285
+ def parse_rows(&block)
286
+ csv_options = ::RowBoat::Helpers.extract_csv_options(merged_options)
287
+ ::SmarterCSV.process(csv_source, csv_options, &block)
288
+ end
289
+
290
+ # @api private
291
+ # @private
292
+ def transaction_if_needed
293
+ if merged_options[:wrap_in_transaction]
294
+ import_into.transaction do
295
+ yield
296
+ raise ActiveRecord::Rollback if rollback_transaction?
297
+ end
298
+ else
299
+ yield
300
+ end
301
+ end
302
+
303
+ # @api private
304
+ # @private
305
+ def process_import_results(import_results)
306
+ import_results.each_with_object(
307
+ invalid_records: [],
308
+ total_inserts: 0,
309
+ inserted_ids: [],
310
+ skipped_rows: skipped_rows
311
+ ) do |import_result, total_results|
312
+ total_results[:invalid_records] += import_result.failed_instances
313
+ total_results[:total_inserts] += import_result.num_inserts
314
+ total_results[:inserted_ids] += import_result.ids
315
+ end
316
+ end
317
+ end
318
+ end