bulk_csv_parser 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +213 -0
- data/app/mailers/bulk_csv_parser/import_mailer.rb +27 -0
- data/app/views/bulk_csv_parser/import_mailer/result_email.html.erb +33 -0
- data/app/views/bulk_csv_parser/import_mailer/result_email.text.erb +18 -0
- data/lib/bulk_csv_parser/configuration.rb +48 -0
- data/lib/bulk_csv_parser/engine.rb +10 -0
- data/lib/bulk_csv_parser/import_job.rb +63 -0
- data/lib/bulk_csv_parser/processor.rb +160 -0
- data/lib/bulk_csv_parser/result.rb +36 -0
- data/lib/bulk_csv_parser/storage.rb +103 -0
- data/lib/bulk_csv_parser/version.rb +5 -0
- data/lib/bulk_csv_parser.rb +50 -0
- data/lib/generators/bulk_csv_parser/install/install_generator.rb +16 -0
- data/lib/generators/bulk_csv_parser/install/templates/bulk_csv_parser.rb +34 -0
- metadata +181 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dea086882061e780057b9254ba51d007762ee28a59b5dd2f48f7e4c29e425c18
|
|
4
|
+
data.tar.gz: 120ece99293ee3037523996b65f9a74b2199e147cd8ebfb0cd9f8c9fc08ec113
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b684e7677a8a438d30e93342c51d71ec8c33598317823eb6099de5d1d5b6a6ab478485db0f53c358923afedf0bc94d0cdad2badc1dc4f509929d6889a9654d2d
|
|
7
|
+
data.tar.gz: c75d50cbf48eec783b677a0e549e3ff667fa2b622c6c72b956a974d41a4c7bf1dddb6766151a8285558ab8e157ed5bd363a5e7464da29f01aadb187b3f49c7b1
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jagrit Bharara
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# BulkCsvParser
|
|
2
|
+
|
|
3
|
+
Generic bulk CSV import/update for any ActiveRecord model. You declare:
|
|
4
|
+
|
|
5
|
+
- **which model** to import into
|
|
6
|
+
- **which attributes** identify an existing record (`find_by`)
|
|
7
|
+
- **which attributes** should be created/updated (`attributes`)
|
|
8
|
+
|
|
9
|
+
...and the gem handles the rest: streaming large files without loading them
|
|
10
|
+
fully into memory, batching, and (optionally) running the whole import in a
|
|
11
|
+
background job.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Rails app's Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "bulk_csv_parser", path: "../csv_gem" # or git: "..." once pushed, or from rubygems once published
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
bin/rails generate bulk_csv_parser:install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This creates `config/initializers/bulk_csv_parser.rb` where you can set
|
|
27
|
+
defaults (batch size, strategy, queue name, upload dir).
|
|
28
|
+
|
|
29
|
+
## Basic usage (synchronous)
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
result = BulkCsvParser.import(
|
|
33
|
+
file_path: params[:file].path,
|
|
34
|
+
model: User,
|
|
35
|
+
find_by: [:email],
|
|
36
|
+
attributes: [:first_name, :last_name, :status]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
result.success_count # => 980
|
|
40
|
+
result.failed_count # => 20
|
|
41
|
+
result.errors # => [{ row: {...}, error: "Email can't be blank" }, ...]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
CSV headers are normalized automatically (`"First Name"` -> `:first_name`).
|
|
45
|
+
If your CSV headers don't match your model's attribute names, map them:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
BulkCsvParser.import(
|
|
49
|
+
file_path: "/tmp/users.csv",
|
|
50
|
+
model: User,
|
|
51
|
+
find_by: [:email],
|
|
52
|
+
attributes: [:first_name, :status],
|
|
53
|
+
header_mapping: { "Email Address" => :email, "Full Name" => :first_name }
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Need custom logic per row (parsing dates, computing derived fields, skipping
|
|
58
|
+
rows)? Pass `transform`, which receives the normalized row hash and returns
|
|
59
|
+
the hash to use (or `nil` to skip the row):
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
BulkCsvParser.import(
|
|
63
|
+
file_path: "/tmp/users.csv",
|
|
64
|
+
model: User,
|
|
65
|
+
find_by: [:email],
|
|
66
|
+
attributes: [:status, :activated_at],
|
|
67
|
+
transform: ->(row) {
|
|
68
|
+
row[:status] = row[:status].to_s.downcase
|
|
69
|
+
row[:activated_at] = Date.parse(row[:activated_at]) rescue nil
|
|
70
|
+
row
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Background processing (large files, and workers on a different machine)
|
|
76
|
+
|
|
77
|
+
Uploaded tempfiles disappear once the request ends, and in production your
|
|
78
|
+
job queue is very often processed on a **different machine** than the one
|
|
79
|
+
that received the upload (a separate Sidekiq/Resque/GoodJob fleet) with no
|
|
80
|
+
access to that web server's local disk. Passing a bare `file_path` across
|
|
81
|
+
that boundary silently breaks.
|
|
82
|
+
|
|
83
|
+
`BulkCsvParser.enqueue` solves this via `BulkCsvParser::Storage`: it uploads
|
|
84
|
+
the file to whatever service Active Storage is configured with
|
|
85
|
+
(`config/storage.yml` — S3, GCS, Azure) and passes a small signed reference
|
|
86
|
+
to the job instead of a path. Any worker with the same storage credentials
|
|
87
|
+
can fetch it back, regardless of which machine it runs on:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class ImportsController < ApplicationController
|
|
91
|
+
def create
|
|
92
|
+
BulkCsvParser.enqueue(
|
|
93
|
+
file: params[:file], # anything responding to #path
|
|
94
|
+
model: User,
|
|
95
|
+
find_by: [:email],
|
|
96
|
+
attributes: [:first_name, :last_name, :status],
|
|
97
|
+
notify_email: current_user.email
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
redirect_to imports_path, notice: "Import queued"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This requires Active Storage to be set up (`bin/rails active_storage:install`
|
|
106
|
+
and a non-`:local` service configured for any multi-machine deployment — the
|
|
107
|
+
local disk service has the exact same cross-machine problem the file upload
|
|
108
|
+
itself does). `config.storage` defaults to `:active_storage` automatically
|
|
109
|
+
whenever Active Storage is loaded.
|
|
110
|
+
|
|
111
|
+
If web and worker genuinely share a disk (single-machine deploys, or a
|
|
112
|
+
shared NFS/EFS mount), you can opt into the simpler local-copy adapter:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
BulkCsvParser.configure { |c| c.storage = :local } # config/initializers/bulk_csv_parser.rb
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You can also enqueue the job directly if you already have a storage
|
|
119
|
+
reference:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
BulkCsvParser::ImportJob.perform_later(
|
|
123
|
+
file_reference: some_active_storage_blob.signed_id,
|
|
124
|
+
model: "User",
|
|
125
|
+
find_by: [:email],
|
|
126
|
+
attributes: [:first_name, :last_name],
|
|
127
|
+
notify_email: "ops@example.com"
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The job runs on whatever ActiveJob adapter your app uses (Sidekiq, Resque,
|
|
132
|
+
GoodJob, etc.) on the `bulk_csv_parser` queue (configurable), and always
|
|
133
|
+
deletes/purges the stored file when it finishes, even on failure.
|
|
134
|
+
|
|
135
|
+
## Email notification of results
|
|
136
|
+
|
|
137
|
+
Pass `notify_email:` to `BulkCsvParser.enqueue` or `ImportJob.perform_later`
|
|
138
|
+
and, once the import finishes, `BulkCsvParser::ImportMailer` sends an email
|
|
139
|
+
with the success count, failure count, and the reason each failed row
|
|
140
|
+
failed (capped at `config.notify_error_limit`, default 25, so a file with
|
|
141
|
+
thousands of bad rows doesn't produce a giant email):
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
BulkCsvParser.enqueue(
|
|
145
|
+
file: params[:file],
|
|
146
|
+
model: User,
|
|
147
|
+
find_by: [:email],
|
|
148
|
+
attributes: [:first_name, :status],
|
|
149
|
+
notify_email: "ops@example.com"
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Set the sender address in the initializer:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
BulkCsvParser.configure { |c| c.mailer_sender = "imports@yourapp.com" }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Strategies
|
|
160
|
+
|
|
161
|
+
- `:save` (default) — `find_or_initialize_by` + `assign_attributes` + `save`
|
|
162
|
+
per row. Runs validations and callbacks. Safer, slower.
|
|
163
|
+
- `:upsert` — batches rows into a single `upsert_all` call per batch. Much
|
|
164
|
+
faster for very large files, but **skips validations/callbacks** and
|
|
165
|
+
requires a DB-level unique index covering the `find_by` columns.
|
|
166
|
+
`created_at` is only set on insert and left alone on conflict, so
|
|
167
|
+
re-importing an existing record won't clobber when it was originally
|
|
168
|
+
created — only `updated_at` and the declared `attributes` change on update.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
BulkCsvParser.import(
|
|
172
|
+
file_path: "/tmp/huge_import.csv",
|
|
173
|
+
model: Product,
|
|
174
|
+
find_by: [:sku],
|
|
175
|
+
attributes: [:name, :price, :stock],
|
|
176
|
+
strategy: :upsert,
|
|
177
|
+
batch_size: 5000
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Large files
|
|
182
|
+
|
|
183
|
+
The processor uses `CSV.foreach` to stream row by row and only holds
|
|
184
|
+
`batch_size` rows in memory at a time, so multi-GB files are safe to import.
|
|
185
|
+
Tune `batch_size` (default 1000) to trade off memory usage vs. number of DB
|
|
186
|
+
round-trips.
|
|
187
|
+
|
|
188
|
+
## Configuration
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
BulkCsvParser.configure do |config|
|
|
192
|
+
config.batch_size = 1000
|
|
193
|
+
config.strategy = :save
|
|
194
|
+
config.queue_name = :bulk_csv_parser
|
|
195
|
+
config.upload_dir = Rails.root.join("tmp", "bulk_csv_parser")
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Handling row-level errors
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
BulkCsvParser.import(
|
|
203
|
+
file_path: "/tmp/users.csv",
|
|
204
|
+
model: User,
|
|
205
|
+
find_by: [:email],
|
|
206
|
+
attributes: [:status],
|
|
207
|
+
on_row_error: ->(row, error) { Rails.logger.error("Row failed: #{row} - #{error.message}") }
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkCsvParser
|
|
4
|
+
class ImportMailer < ActionMailer::Base
|
|
5
|
+
default from: -> { BulkCsvParser.configuration.mailer_sender }
|
|
6
|
+
|
|
7
|
+
# @param to [String, Array<String>]
|
|
8
|
+
# @param model [String, Class] model name/class the import ran against
|
|
9
|
+
# @param result [BulkCsvParser::Result]
|
|
10
|
+
def result_email(to:, model:, result:)
|
|
11
|
+
@model = model
|
|
12
|
+
@result = result
|
|
13
|
+
|
|
14
|
+
limit = BulkCsvParser.configuration.notify_error_limit
|
|
15
|
+
@errors = result.errors.first(limit)
|
|
16
|
+
@more_errors_count = [result.errors.size - @errors.size, 0].max
|
|
17
|
+
|
|
18
|
+
status = result.success? ? "succeeded" : "completed with errors"
|
|
19
|
+
|
|
20
|
+
mail(
|
|
21
|
+
to: to,
|
|
22
|
+
subject: "CSV import for #{@model} #{status}: " \
|
|
23
|
+
"#{result.success_count} succeeded, #{result.failed_count} failed"
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<h2>CSV import for <%= @model %></h2>
|
|
2
|
+
|
|
3
|
+
<ul>
|
|
4
|
+
<li>Succeeded: <strong><%= @result.success_count %></strong></li>
|
|
5
|
+
<li>Failed: <strong><%= @result.failed_count %></strong></li>
|
|
6
|
+
<li>Total: <%= @result.total %></li>
|
|
7
|
+
</ul>
|
|
8
|
+
|
|
9
|
+
<% if @errors.any? %>
|
|
10
|
+
<h3>Failed rows</h3>
|
|
11
|
+
<table border="1" cellpadding="6" cellspacing="0">
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Row</th>
|
|
15
|
+
<th>Reason</th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% @errors.each do |error| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%= error[:row].inspect %></td>
|
|
22
|
+
<td><%= error[:error] %></td>
|
|
23
|
+
</tr>
|
|
24
|
+
<% end %>
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
27
|
+
|
|
28
|
+
<% if @more_errors_count.positive? %>
|
|
29
|
+
<p>...and <%= @more_errors_count %> more failed row(s) not shown.</p>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% else %>
|
|
32
|
+
<p>No failed rows.</p>
|
|
33
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CSV import for <%= @model %>
|
|
2
|
+
|
|
3
|
+
Succeeded: <%= @result.success_count %>
|
|
4
|
+
Failed: <%= @result.failed_count %>
|
|
5
|
+
Total: <%= @result.total %>
|
|
6
|
+
|
|
7
|
+
<% if @errors.any? -%>
|
|
8
|
+
Failed rows:
|
|
9
|
+
<% @errors.each do |error| -%>
|
|
10
|
+
- <%= error[:row].inspect %>
|
|
11
|
+
reason: <%= error[:error] %>
|
|
12
|
+
<% end -%>
|
|
13
|
+
<% if @more_errors_count.positive? -%>
|
|
14
|
+
... and <%= @more_errors_count %> more failed row(s) not shown.
|
|
15
|
+
<% end -%>
|
|
16
|
+
<% else -%>
|
|
17
|
+
No failed rows.
|
|
18
|
+
<% end -%>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkCsvParser
|
|
4
|
+
class Configuration
|
|
5
|
+
# Rows read into memory per batch before a DB round-trip happens.
|
|
6
|
+
attr_accessor :batch_size
|
|
7
|
+
|
|
8
|
+
# :save -> find_or_initialize_by + assign_attributes + save (runs validations/callbacks)
|
|
9
|
+
# :upsert -> ActiveRecord#upsert_all (fast, bulk, skips validations/callbacks)
|
|
10
|
+
attr_accessor :strategy
|
|
11
|
+
|
|
12
|
+
# ActiveJob queue used by BulkCsvParser::ImportJob
|
|
13
|
+
attr_accessor :queue_name
|
|
14
|
+
|
|
15
|
+
# :active_storage -> uploads the CSV to whatever service is configured in
|
|
16
|
+
# config/storage.yml (S3/GCS/Azure). Required whenever
|
|
17
|
+
# the job's queue is processed by a different machine
|
|
18
|
+
# than the one that received the upload, since a local
|
|
19
|
+
# file path from the web server won't resolve on the
|
|
20
|
+
# worker box. Falls back to :local automatically when
|
|
21
|
+
# ActiveStorage isn't loaded.
|
|
22
|
+
# :local -> copies the file to `upload_dir` on the local disk.
|
|
23
|
+
# Only safe when web and worker share that disk (e.g.
|
|
24
|
+
# single-machine deploys, or a shared NFS mount).
|
|
25
|
+
attr_accessor :storage
|
|
26
|
+
|
|
27
|
+
# Directory used by the :local storage adapter.
|
|
28
|
+
attr_accessor :upload_dir
|
|
29
|
+
|
|
30
|
+
# Default "from" address for BulkCsvParser::ImportMailer.
|
|
31
|
+
attr_accessor :mailer_sender
|
|
32
|
+
|
|
33
|
+
# Max number of row errors listed in the result notification email
|
|
34
|
+
# (the rest are summarized as a count, so the email doesn't balloon
|
|
35
|
+
# on a file with thousands of bad rows).
|
|
36
|
+
attr_accessor :notify_error_limit
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@batch_size = 1000
|
|
40
|
+
@strategy = :save
|
|
41
|
+
@queue_name = :bulk_csv_parser
|
|
42
|
+
@storage = defined?(::ActiveStorage) ? :active_storage : :local
|
|
43
|
+
@upload_dir = defined?(Rails) ? Rails.root.join("tmp", "bulk_csv_parser") : "tmp/bulk_csv_parser"
|
|
44
|
+
@mailer_sender = "no-reply@example.com"
|
|
45
|
+
@notify_error_limit = 25
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkCsvParser
|
|
4
|
+
if defined?(::Rails::Engine)
|
|
5
|
+
# Registers app/mailers and app/views so the host Rails app can autoload
|
|
6
|
+
# BulkCsvParser::ImportMailer and find its view templates.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkCsvParser
|
|
4
|
+
# Runs BulkCsvParser::Processor in the background via ActiveJob, so large
|
|
5
|
+
# CSV uploads don't block a web request. Enqueue it via BulkCsvParser.enqueue
|
|
6
|
+
# rather than calling perform_later directly -- that helper is what puts the
|
|
7
|
+
# file somewhere the worker (often a different machine than the one that
|
|
8
|
+
# received the upload) can actually read it back from.
|
|
9
|
+
class ImportJob < ActiveJob::Base
|
|
10
|
+
queue_as { BulkCsvParser.configuration.queue_name }
|
|
11
|
+
|
|
12
|
+
discard_on BulkCsvParser::Processor::ConfigurationError
|
|
13
|
+
|
|
14
|
+
def perform(
|
|
15
|
+
file_reference:,
|
|
16
|
+
model:,
|
|
17
|
+
find_by:,
|
|
18
|
+
attributes:,
|
|
19
|
+
header_mapping: {},
|
|
20
|
+
batch_size: nil,
|
|
21
|
+
strategy: nil,
|
|
22
|
+
notify_email: nil
|
|
23
|
+
)
|
|
24
|
+
local_file = BulkCsvParser::Storage.fetch(file_reference)
|
|
25
|
+
path = local_file.respond_to?(:path) ? local_file.path : local_file
|
|
26
|
+
|
|
27
|
+
result = BulkCsvParser::Processor.new(
|
|
28
|
+
file_path: path,
|
|
29
|
+
model: model,
|
|
30
|
+
find_by: find_by,
|
|
31
|
+
attributes: attributes,
|
|
32
|
+
header_mapping: header_mapping,
|
|
33
|
+
batch_size: batch_size,
|
|
34
|
+
strategy: strategy
|
|
35
|
+
).call
|
|
36
|
+
|
|
37
|
+
log(model, result)
|
|
38
|
+
notify(notify_email, model, result)
|
|
39
|
+
result
|
|
40
|
+
ensure
|
|
41
|
+
local_file.close! if local_file.respond_to?(:close!)
|
|
42
|
+
BulkCsvParser::Storage.cleanup(file_reference)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def log(model, result)
|
|
48
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
49
|
+
|
|
50
|
+
Rails.logger.info(
|
|
51
|
+
"[BulkCsvParser] model=#{model} success=#{result.success_count} " \
|
|
52
|
+
"failed=#{result.failed_count} total=#{result.total}"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def notify(notify_email, model, result)
|
|
57
|
+
return unless notify_email
|
|
58
|
+
return unless defined?(BulkCsvParser::ImportMailer)
|
|
59
|
+
|
|
60
|
+
BulkCsvParser::ImportMailer.result_email(to: notify_email, model: model, result: result).deliver_now
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module BulkCsvParser
|
|
6
|
+
# Generic, model-agnostic CSV importer/updater.
|
|
7
|
+
#
|
|
8
|
+
# Streams the CSV row by row (CSV.foreach) so arbitrarily large files never
|
|
9
|
+
# get loaded into memory at once, batches rows, and either upserts them in
|
|
10
|
+
# bulk or finds/updates each record with full validation support.
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
#
|
|
14
|
+
# BulkCsvParser::Processor.new(
|
|
15
|
+
# file_path: "/tmp/users.csv",
|
|
16
|
+
# model: User,
|
|
17
|
+
# find_by: [:email],
|
|
18
|
+
# attributes: [:first_name, :last_name, :status],
|
|
19
|
+
# header_mapping: { "Email Address" => :email }
|
|
20
|
+
# ).call
|
|
21
|
+
#
|
|
22
|
+
class Processor
|
|
23
|
+
class ConfigurationError < StandardError; end
|
|
24
|
+
|
|
25
|
+
def initialize(
|
|
26
|
+
model:,
|
|
27
|
+
find_by:,
|
|
28
|
+
attributes:,
|
|
29
|
+
file_path: nil,
|
|
30
|
+
file: nil,
|
|
31
|
+
header_mapping: {},
|
|
32
|
+
batch_size: nil,
|
|
33
|
+
strategy: nil,
|
|
34
|
+
transform: nil,
|
|
35
|
+
on_row_error: nil
|
|
36
|
+
)
|
|
37
|
+
@model = model.is_a?(String) ? model.constantize : model
|
|
38
|
+
@file_path = file_path
|
|
39
|
+
@file = file
|
|
40
|
+
@find_by = Array(find_by).map(&:to_sym)
|
|
41
|
+
@attributes = Array(attributes).map(&:to_sym)
|
|
42
|
+
@header_mapping = header_mapping.each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_sym }
|
|
43
|
+
@batch_size = batch_size || BulkCsvParser.configuration.batch_size
|
|
44
|
+
@strategy = (strategy || BulkCsvParser.configuration.strategy).to_sym
|
|
45
|
+
@transform = transform
|
|
46
|
+
@on_row_error = on_row_error
|
|
47
|
+
@result = Result.new
|
|
48
|
+
|
|
49
|
+
validate_arguments!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call
|
|
53
|
+
each_batch do |batch|
|
|
54
|
+
next if batch.empty?
|
|
55
|
+
|
|
56
|
+
strategy == :upsert ? process_upsert(batch) : process_save(batch)
|
|
57
|
+
end
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
attr_reader :model, :find_by, :attributes, :header_mapping, :batch_size,
|
|
64
|
+
:strategy, :transform, :on_row_error, :result
|
|
65
|
+
|
|
66
|
+
def validate_arguments!
|
|
67
|
+
raise ConfigurationError, "model must respond to .find_or_initialize_by / .upsert_all" unless model.respond_to?(:find_or_initialize_by)
|
|
68
|
+
raise ConfigurationError, "find_by can't be empty" if find_by.empty?
|
|
69
|
+
raise ConfigurationError, "attributes can't be empty" if attributes.empty?
|
|
70
|
+
raise ConfigurationError, "provide either file_path or file" if @file_path.nil? && @file.nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Yields arrays of Hash(attribute_symbol => value) with at most batch_size elements.
|
|
74
|
+
def each_batch
|
|
75
|
+
batch = []
|
|
76
|
+
|
|
77
|
+
CSV.foreach(csv_source, headers: true, encoding: "bom|utf-8:utf-8") do |row|
|
|
78
|
+
attrs = extract_attributes(row)
|
|
79
|
+
next if attrs.nil? # allow transform to skip/filter a row by returning nil
|
|
80
|
+
|
|
81
|
+
batch << attrs
|
|
82
|
+
|
|
83
|
+
if batch.size >= batch_size
|
|
84
|
+
yield batch
|
|
85
|
+
batch = []
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
yield batch unless batch.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def csv_source
|
|
93
|
+
@file_path || @file
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_attributes(row)
|
|
97
|
+
hash = {}
|
|
98
|
+
|
|
99
|
+
row.headers.each do |header|
|
|
100
|
+
next if header.nil?
|
|
101
|
+
|
|
102
|
+
key = header_mapping[header] || normalize_header(header)
|
|
103
|
+
hash[key] = row[header]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
transform ? transform.call(hash) : hash
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def normalize_header(header)
|
|
110
|
+
header.to_s.strip.downcase.gsub(/\s+/, "_").to_sym
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Safe, validation-aware strategy: one find_or_initialize_by + save per row.
|
|
114
|
+
def process_save(batch)
|
|
115
|
+
batch.each do |attrs|
|
|
116
|
+
find_attrs = attrs.slice(*find_by)
|
|
117
|
+
update_attrs = attrs.slice(*attributes)
|
|
118
|
+
|
|
119
|
+
record = model.find_or_initialize_by(find_attrs)
|
|
120
|
+
record.assign_attributes(update_attrs)
|
|
121
|
+
|
|
122
|
+
if record.save
|
|
123
|
+
result.increment_success
|
|
124
|
+
else
|
|
125
|
+
result.add_error(attrs, record.errors.full_messages.join(", "))
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
on_row_error&.call(attrs, e)
|
|
129
|
+
result.add_error(attrs, e.message)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fast, bulk strategy: a single upsert_all per batch. Skips validations and
|
|
134
|
+
# callbacks -- requires a DB unique index covering the find_by columns.
|
|
135
|
+
def process_upsert(batch)
|
|
136
|
+
now = defined?(Time.current) ? Time.current : Time.now
|
|
137
|
+
has_created_at = model.column_names.include?("created_at")
|
|
138
|
+
has_updated_at = model.column_names.include?("updated_at")
|
|
139
|
+
|
|
140
|
+
rows = batch.map do |attrs|
|
|
141
|
+
row = attrs.slice(*attributes).merge(attrs.slice(*find_by))
|
|
142
|
+
row[:created_at] = now if has_created_at
|
|
143
|
+
row[:updated_at] = now if has_updated_at
|
|
144
|
+
row
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# update_only excludes created_at from the ON CONFLICT ... SET clause,
|
|
148
|
+
# so it's only written on insert -- otherwise upsert_all would
|
|
149
|
+
# overwrite an existing record's created_at on every re-import.
|
|
150
|
+
update_only = attributes.dup
|
|
151
|
+
update_only << :updated_at if has_updated_at
|
|
152
|
+
|
|
153
|
+
model.upsert_all(rows, unique_by: find_by, update_only: update_only)
|
|
154
|
+
result.increment_success(rows.size)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
on_row_error&.call(batch, e)
|
|
157
|
+
result.add_error(batch, e.message)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkCsvParser
|
|
4
|
+
# Collects the outcome of a Processor run: how many rows succeeded,
|
|
5
|
+
# how many failed, and the reason each failure happened.
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :success_count, :failed_count, :errors
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@success_count = 0
|
|
11
|
+
@failed_count = 0
|
|
12
|
+
@errors = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def increment_success(by = 1)
|
|
16
|
+
@success_count += by
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_error(row, message)
|
|
20
|
+
@failed_count += 1
|
|
21
|
+
@errors << { row: row, error: message }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def total
|
|
25
|
+
success_count + failed_count
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def success?
|
|
29
|
+
failed_count.zero?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
{ success_count: success_count, failed_count: failed_count, total: total, errors: errors }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module BulkCsvParser
|
|
6
|
+
# Moves the uploaded CSV from the machine that received the HTTP request to
|
|
7
|
+
# wherever ImportJob actually executes -- which, in production, is very
|
|
8
|
+
# often a separate worker box (Sidekiq/Resque/GoodJob fleet) with no access
|
|
9
|
+
# to the web server's local disk or its request-scoped tempfile.
|
|
10
|
+
#
|
|
11
|
+
# `store` is called on the web process and returns a small, serializable
|
|
12
|
+
# "reference" (a String) that's safe to pass as an ActiveJob argument.
|
|
13
|
+
# `fetch` is called on the worker process and turns that reference back
|
|
14
|
+
# into a local file it can stream with CSV.foreach. `cleanup` removes the
|
|
15
|
+
# persisted copy once the job is done with it.
|
|
16
|
+
class Storage
|
|
17
|
+
class UnknownAdapterError < StandardError; end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def store(file)
|
|
21
|
+
adapter.store(file)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns an object responding to #path; call #cleanup_local(result) on
|
|
25
|
+
# it when done reading, if it responds to that (see Local::Handle).
|
|
26
|
+
def fetch(reference)
|
|
27
|
+
adapter.fetch(reference)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cleanup(reference)
|
|
31
|
+
adapter.cleanup(reference)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def adapter
|
|
37
|
+
case BulkCsvParser.configuration.storage
|
|
38
|
+
when :active_storage
|
|
39
|
+
ActiveStorageAdapter
|
|
40
|
+
when :local
|
|
41
|
+
LocalAdapter
|
|
42
|
+
else
|
|
43
|
+
raise UnknownAdapterError, "unknown storage adapter #{BulkCsvParser.configuration.storage.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Copies to local disk. Only correct when the enqueuing process and the
|
|
49
|
+
# job's execution process share that disk.
|
|
50
|
+
module LocalAdapter
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
def store(file)
|
|
54
|
+
dir = BulkCsvParser.configuration.upload_dir
|
|
55
|
+
FileUtils.mkdir_p(dir)
|
|
56
|
+
|
|
57
|
+
destination = File.join(dir.to_s, "#{SecureRandom.uuid}.csv")
|
|
58
|
+
FileUtils.cp(file.path, destination)
|
|
59
|
+
destination
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fetch(reference)
|
|
63
|
+
reference # already a local path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cleanup(reference)
|
|
67
|
+
File.delete(reference) if reference && File.exist?(reference)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Uploads to whatever service config/storage.yml points at (S3, GCS,
|
|
72
|
+
# Azure, ...). The reference is an ActiveStorage signed_id: safe to hand
|
|
73
|
+
# to a job that runs on any machine with the same storage credentials.
|
|
74
|
+
module ActiveStorageAdapter
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
def store(file)
|
|
78
|
+
filename = file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.path)
|
|
79
|
+
|
|
80
|
+
blob = ::ActiveStorage::Blob.create_and_upload!(
|
|
81
|
+
io: File.open(file.path),
|
|
82
|
+
filename: filename,
|
|
83
|
+
content_type: "text/csv"
|
|
84
|
+
)
|
|
85
|
+
blob.signed_id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch(reference)
|
|
89
|
+
blob = ::ActiveStorage::Blob.find_signed!(reference)
|
|
90
|
+
|
|
91
|
+
tempfile = Tempfile.new(["bulk_csv_parser", ".csv"])
|
|
92
|
+
tempfile.binmode
|
|
93
|
+
blob.download { |chunk| tempfile.write(chunk) }
|
|
94
|
+
tempfile.rewind
|
|
95
|
+
tempfile
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def cleanup(reference)
|
|
99
|
+
::ActiveStorage::Blob.find_signed(reference)&.purge
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "bulk_csv_parser/version"
|
|
6
|
+
require "bulk_csv_parser/configuration"
|
|
7
|
+
require "bulk_csv_parser/result"
|
|
8
|
+
require "bulk_csv_parser/processor"
|
|
9
|
+
require "bulk_csv_parser/storage"
|
|
10
|
+
require "bulk_csv_parser/import_job" if defined?(ActiveJob)
|
|
11
|
+
require "bulk_csv_parser/engine" if defined?(Rails::Engine)
|
|
12
|
+
|
|
13
|
+
module BulkCsvParser
|
|
14
|
+
class << self
|
|
15
|
+
attr_writer :configuration
|
|
16
|
+
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Synchronous import, runs in the current process/request.
|
|
26
|
+
def import(**options)
|
|
27
|
+
Processor.new(**options).call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Stores the uploaded file (via BulkCsvParser::Storage -- Active Storage
|
|
31
|
+
# by default, so it lands on S3/GCS/Azure and any worker machine can read
|
|
32
|
+
# it back) then enqueues BulkCsvParser::ImportJob. `file` can be an
|
|
33
|
+
# ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile, or
|
|
34
|
+
# anything responding to #path.
|
|
35
|
+
#
|
|
36
|
+
# Pass notify_email: "someone@example.com" to have ImportJob email the
|
|
37
|
+
# success/failure counts and per-row failure reasons once it's done.
|
|
38
|
+
def enqueue(file:, model:, find_by:, attributes:, **options)
|
|
39
|
+
reference = Storage.store(file)
|
|
40
|
+
|
|
41
|
+
ImportJob.perform_later(
|
|
42
|
+
file_reference: reference,
|
|
43
|
+
model: model.to_s,
|
|
44
|
+
find_by: find_by,
|
|
45
|
+
attributes: attributes,
|
|
46
|
+
**options
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module BulkCsvParser
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
desc "Creates a BulkCsvParser initializer"
|
|
10
|
+
|
|
11
|
+
def copy_initializer
|
|
12
|
+
template "bulk_csv_parser.rb", "config/initializers/bulk_csv_parser.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
BulkCsvParser.configure do |config|
|
|
4
|
+
# Rows read into memory per batch before a DB round-trip happens.
|
|
5
|
+
config.batch_size = 1000
|
|
6
|
+
|
|
7
|
+
# :save -> validations/callbacks run, safe default
|
|
8
|
+
# :upsert -> ActiveRecord#upsert_all, fastest for huge files, needs a DB
|
|
9
|
+
# unique index on the find_by columns, skips validations/callbacks
|
|
10
|
+
config.strategy = :save
|
|
11
|
+
|
|
12
|
+
# ActiveJob queue used by BulkCsvParser::ImportJob
|
|
13
|
+
config.queue_name = :bulk_csv_parser
|
|
14
|
+
|
|
15
|
+
# :active_storage (default when Active Storage is installed) uploads the
|
|
16
|
+
# CSV to whatever service config/storage.yml points at (S3/GCS/Azure) so
|
|
17
|
+
# any worker machine can read it back -- REQUIRED if your job queue is
|
|
18
|
+
# processed on a different machine than your web servers.
|
|
19
|
+
#
|
|
20
|
+
# :local copies the file to `upload_dir` on local disk. Only safe when web
|
|
21
|
+
# and worker processes share that disk.
|
|
22
|
+
config.storage = :active_storage
|
|
23
|
+
|
|
24
|
+
# Used only by the :local storage adapter.
|
|
25
|
+
config.upload_dir = Rails.root.join("tmp", "bulk_csv_parser")
|
|
26
|
+
|
|
27
|
+
# "From" address for the result notification email
|
|
28
|
+
# (BulkCsvParser::ImportMailer), sent when you pass notify_email: to
|
|
29
|
+
# BulkCsvParser.enqueue.
|
|
30
|
+
config.mailer_sender = "no-reply@#{Rails.application.class.module_parent_name.underscore.dasherize}.com"
|
|
31
|
+
|
|
32
|
+
# Max number of failed rows listed in the notification email.
|
|
33
|
+
config.notify_error_limit = 25
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bulk_csv_parser
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jagrit Bharara
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: csv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activejob
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.0'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '9'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '6.0'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '9'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: activerecord
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '6.0'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '9'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '6.0'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '9'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: activesupport
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '6.0'
|
|
73
|
+
- - "<"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '9'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '6.0'
|
|
83
|
+
- - "<"
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '9'
|
|
86
|
+
- !ruby/object:Gem::Dependency
|
|
87
|
+
name: rspec
|
|
88
|
+
requirement: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - "~>"
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '3.0'
|
|
93
|
+
type: :development
|
|
94
|
+
prerelease: false
|
|
95
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - "~>"
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.0'
|
|
100
|
+
- !ruby/object:Gem::Dependency
|
|
101
|
+
name: sqlite3
|
|
102
|
+
requirement: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - "~>"
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '2.0'
|
|
107
|
+
type: :development
|
|
108
|
+
prerelease: false
|
|
109
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - "~>"
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '2.0'
|
|
114
|
+
- !ruby/object:Gem::Dependency
|
|
115
|
+
name: actionmailer
|
|
116
|
+
requirement: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '6.0'
|
|
121
|
+
- - "<"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '9'
|
|
124
|
+
type: :development
|
|
125
|
+
prerelease: false
|
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '6.0'
|
|
131
|
+
- - "<"
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: '9'
|
|
134
|
+
description: Drop-in gem to bulk-import or bulk-update large CSV files into any ActiveRecord
|
|
135
|
+
model by declaring which model, which columns identify existing records, and which
|
|
136
|
+
columns to update. Streams the file so it never loads fully into memory, and ships
|
|
137
|
+
an ActiveJob worker so imports run in the background.
|
|
138
|
+
email:
|
|
139
|
+
- jagritbharara12@gmail.com
|
|
140
|
+
executables: []
|
|
141
|
+
extensions: []
|
|
142
|
+
extra_rdoc_files: []
|
|
143
|
+
files:
|
|
144
|
+
- LICENSE.txt
|
|
145
|
+
- README.md
|
|
146
|
+
- app/mailers/bulk_csv_parser/import_mailer.rb
|
|
147
|
+
- app/views/bulk_csv_parser/import_mailer/result_email.html.erb
|
|
148
|
+
- app/views/bulk_csv_parser/import_mailer/result_email.text.erb
|
|
149
|
+
- lib/bulk_csv_parser.rb
|
|
150
|
+
- lib/bulk_csv_parser/configuration.rb
|
|
151
|
+
- lib/bulk_csv_parser/engine.rb
|
|
152
|
+
- lib/bulk_csv_parser/import_job.rb
|
|
153
|
+
- lib/bulk_csv_parser/processor.rb
|
|
154
|
+
- lib/bulk_csv_parser/result.rb
|
|
155
|
+
- lib/bulk_csv_parser/storage.rb
|
|
156
|
+
- lib/bulk_csv_parser/version.rb
|
|
157
|
+
- lib/generators/bulk_csv_parser/install/install_generator.rb
|
|
158
|
+
- lib/generators/bulk_csv_parser/install/templates/bulk_csv_parser.rb
|
|
159
|
+
homepage: https://example.com/bulk_csv_parser
|
|
160
|
+
licenses:
|
|
161
|
+
- MIT
|
|
162
|
+
metadata: {}
|
|
163
|
+
rdoc_options: []
|
|
164
|
+
require_paths:
|
|
165
|
+
- lib
|
|
166
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
167
|
+
requirements:
|
|
168
|
+
- - ">="
|
|
169
|
+
- !ruby/object:Gem::Version
|
|
170
|
+
version: 2.7.0
|
|
171
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
172
|
+
requirements:
|
|
173
|
+
- - ">="
|
|
174
|
+
- !ruby/object:Gem::Version
|
|
175
|
+
version: '0'
|
|
176
|
+
requirements: []
|
|
177
|
+
rubygems_version: 3.6.9
|
|
178
|
+
specification_version: 4
|
|
179
|
+
summary: Generic bulk CSV import/update for any ActiveRecord model, with a background
|
|
180
|
+
worker.
|
|
181
|
+
test_files: []
|