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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkCsvParser
4
+ VERSION = "0.1.0"
5
+ 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: []