active_record_importer 0.3.0 → 1.0.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 +5 -5
- data/CHANGELOG.md +40 -0
- data/Gemfile +7 -0
- data/README.md +72 -5
- data/active_record_importer.gemspec +8 -4
- data/lib/active_record_importer/attribute/attributes_builder.rb +1 -0
- data/lib/active_record_importer/batch_importer.rb +61 -34
- data/lib/active_record_importer/batch_persister.rb +103 -0
- data/lib/active_record_importer/data_processor.rb +72 -26
- data/lib/active_record_importer/dispatcher.rb +34 -4
- data/lib/active_record_importer/failed_file_builder.rb +12 -55
- data/lib/active_record_importer/import_callbacker.rb +2 -2
- data/lib/active_record_importer/importable.rb +11 -4
- data/lib/active_record_importer/instance_builder.rb +17 -9
- data/lib/active_record_importer/options_builder.rb +4 -0
- data/lib/active_record_importer/record_preloader.rb +72 -0
- data/lib/active_record_importer/version.rb +1 -1
- data/lib/active_record_importer.rb +6 -1
- data/spec/active_record_importer/attribute/builders_spec.rb +51 -0
- data/spec/active_record_importer/batch_persister_spec.rb +87 -0
- data/spec/active_record_importer/data_processor_spec.rb +89 -0
- data/spec/active_record_importer/failed_file_builder_spec.rb +26 -0
- data/spec/active_record_importer/import_callbacker_spec.rb +25 -0
- data/spec/active_record_importer/instance_builder_spec.rb +77 -0
- data/spec/active_record_importer/options_builder_spec.rb +19 -0
- data/spec/active_record_importer/record_preloader_spec.rb +59 -0
- data/spec/active_record_importer_spec.rb +1 -5
- data/spec/integration/import_spec.rb +457 -0
- data/spec/spec_helper.rb +41 -0
- data/spec/support/csv_helper.rb +42 -0
- data/spec/support/database.rb +100 -0
- data/spec/support/models.rb +112 -0
- data/spec/support/query_counter.rb +22 -0
- metadata +58 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a9432ca865104e5e0298a6cc01cbdedd8d5e5512ab8418f54301731aad44994e
|
|
4
|
+
data.tar.gz: 84c9f8bd766fb6d81194b7c0ab44b65447f0fd18562bfc9b91630e4af6f861c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09eb496352a2b2b29f3a79f033689167b6bf9f743e0b14da5364211e60e4176db44d821ea94d23250028eac4ada508171fbcfcfc45cc1a3f93b39c000e4c3608'
|
|
7
|
+
data.tar.gz: c0450b85a0cb842663939cf69f65dd1ac857f351bd761b4f9d1ef84eac39647edb177a644ccb03564a53622525bdb2d872b370b28e681836ecfbeb11dce58359
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Rows are persisted in bulk per CSV chunk through
|
|
7
|
+
[activerecord-import](https://github.com/zdennis/activerecord-import):
|
|
8
|
+
one bulk `INSERT` for new records and one bulk upsert (primary key conflict)
|
|
9
|
+
for existing records, inside a single transaction per chunk.
|
|
10
|
+
- Existing records matched by `find_options` are loaded with a single query per chunk.
|
|
11
|
+
- Validations are kept: every row is validated (`valid?`) before persistence and invalid
|
|
12
|
+
rows are written to the failed file with their errors, exactly as before.
|
|
13
|
+
- When the database rejects a bulk statement (e.g. a unique index without a model
|
|
14
|
+
validation), the group is retried row by row in savepoints so only the offending rows fail.
|
|
15
|
+
- `acts_as_importable bulk_insert: false` saves rows one by one (still one transaction
|
|
16
|
+
per chunk) so the model's own ActiveRecord save callbacks keep running.
|
|
17
|
+
- Spec suite running against PostgreSQL (default) and MySQL (`DB=mysql`).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Bulk mode skips the model's own ActiveRecord save callbacks** (`before_save`,
|
|
21
|
+
`after_create`, ...). Use `bulk_insert: false` if your model depends on them during import.
|
|
22
|
+
Importer hooks (`before_save`, `after_save`, `state_machine_attr`) still run; when
|
|
23
|
+
`after_save` or `state_machine_attr` are configured the chunk is saved row by row.
|
|
24
|
+
- Default `chunk_size` is now 500 (was `nil`, which processed one row at a time).
|
|
25
|
+
- Requires Ruby >= 2.7 and ActiveRecord >= 6.0 (was ActiveRecord >= 4.0).
|
|
26
|
+
- `activerecord-import` is a new runtime dependency.
|
|
27
|
+
- RSpec 3 for development.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Importer options were stored in a class variable shared by every importable model;
|
|
31
|
+
each model now keeps its own options.
|
|
32
|
+
- `default_attributes` given as `Proc` or `Symbol` were never evaluated, and
|
|
33
|
+
`importing: true` was not set on imported instances.
|
|
34
|
+
- `before_save`/`after_save` given as a single `Symbol` or `Proc` raised `NameError`.
|
|
35
|
+
- The failed file header row was not written as CSV.
|
|
36
|
+
- `File.exists?` (removed in Ruby 3.2) replaced with `File.exist?`.
|
|
37
|
+
|
|
38
|
+
## 0.4.0 and earlier
|
|
39
|
+
|
|
40
|
+
See the git history.
|
data/Gemfile
CHANGED
|
@@ -2,3 +2,10 @@ source 'https://rubygems.org'
|
|
|
2
2
|
|
|
3
3
|
# Specify your gem's dependencies in active_record_importer.gemspec
|
|
4
4
|
gemspec
|
|
5
|
+
|
|
6
|
+
# Database adapters used by the spec suite (see README "Development").
|
|
7
|
+
# Select the database with DB=postgresql (default) or DB=mysql.
|
|
8
|
+
group :test do
|
|
9
|
+
gem 'pg'
|
|
10
|
+
gem 'trilogy'
|
|
11
|
+
end
|
data/README.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# ActiveRecordImporter
|
|
2
2
|
|
|
3
|
-
Supports only Rails 4 and 5
|
|
4
|
-
|
|
5
3
|
This gem helps you insert/update records easily. For now, it only accepts CSV file.
|
|
6
4
|
This also helps you monitor how many rows are imported, and how many rows failed.
|
|
7
5
|
This gem also allows you to easily import to any model with few configurations.
|
|
8
6
|
|
|
7
|
+
Rows are validated one by one but persisted **in bulk** (one `INSERT` per chunk,
|
|
8
|
+
one upsert per chunk) through [activerecord-import](https://github.com/zdennis/activerecord-import).
|
|
9
|
+
See [How rows are persisted](#how-rows-are-persisted).
|
|
10
|
+
|
|
11
|
+
The spec suite runs against PostgreSQL and MySQL (ActiveRecord 7.2); see [Development](#development).
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
* Ruby >= 2.7
|
|
16
|
+
* ActiveRecord >= 6.0
|
|
17
|
+
* PostgreSQL (>= 9.5) or MySQL for bulk upserts; other databases fall back to row-by-row saving
|
|
18
|
+
|
|
9
19
|
I'll release an update to enable this on background job.
|
|
10
20
|
|
|
11
21
|
## Installation
|
|
@@ -25,9 +35,17 @@ Or install it yourself as:
|
|
|
25
35
|
$ gem install active_record_importer
|
|
26
36
|
|
|
27
37
|
## Usage
|
|
28
|
-
### For version 0.
|
|
38
|
+
### For version 1.0.0
|
|
39
|
+
|
|
40
|
+
Rows are now validated one by one but persisted in bulk per chunk (see
|
|
41
|
+
[How rows are persisted](#how-rows-are-persisted)). If your model relies on its own
|
|
42
|
+
ActiveRecord save callbacks during import, add `bulk_insert: false` to `acts_as_importable`.
|
|
43
|
+
See the [CHANGELOG](CHANGELOG.md) for the full list of changes.
|
|
29
44
|
|
|
30
|
-
For
|
|
45
|
+
### For version 0.4.0
|
|
46
|
+
|
|
47
|
+
For the newest version (0.4.0), you don't have to create Import table/model and controller.
|
|
48
|
+
I already fixed the errors when there's no Import table/model on version 0.3.0.
|
|
31
49
|
You just need to add the `acts_as_importable` in your model you want to be importable, and you may now run:
|
|
32
50
|
|
|
33
51
|
```ruby
|
|
@@ -231,6 +249,38 @@ end
|
|
|
231
249
|
|
|
232
250
|
`I'll add more options SOON!`
|
|
233
251
|
|
|
252
|
+
### How rows are persisted
|
|
253
|
+
|
|
254
|
+
Every CSV chunk (`chunk_size` / `batch_size` rows, 500 by default) goes through these steps:
|
|
255
|
+
|
|
256
|
+
1. The attributes of every row are built (`default_attributes`, `store_accessors`, timestamps...).
|
|
257
|
+
2. For `upsert` and `error_duplicate`, the existing records matching the `find_options`
|
|
258
|
+
of the whole chunk are loaded with **a single query**.
|
|
259
|
+
3. An instance is built for every row, the `before_save` callback runs, and the instance is
|
|
260
|
+
**validated** (`valid?`). Invalid rows are kept aside with their errors
|
|
261
|
+
(e.g. `Validation failed: Email can't be blank`).
|
|
262
|
+
4. Valid new records are inserted with **one bulk `INSERT`**, and valid existing records are
|
|
263
|
+
updated with **one bulk upsert** on the primary key, inside one transaction per chunk.
|
|
264
|
+
If the database rejects a bulk statement (e.g. a unique index without a model validation),
|
|
265
|
+
that group is retried row by row in savepoints, so only the offending rows fail.
|
|
266
|
+
5. Failed rows are written to the failed file with an `import_errors` column, and
|
|
267
|
+
`imported_rows` / `failed_rows` are updated on the `Import` record.
|
|
268
|
+
|
|
269
|
+
Bulk persistence skips the model's own ActiveRecord save callbacks (like `activerecord-import` does).
|
|
270
|
+
Rows are saved one by one (still within one transaction per chunk) when:
|
|
271
|
+
|
|
272
|
+
* the importer has `after_save` or `state_machine_attr` options, since they need a persisted instance, or
|
|
273
|
+
* bulk insert is disabled for the model:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
class User < ActiveRecord::Base
|
|
277
|
+
acts_as_importable bulk_insert: false
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Duplicates inside the same chunk follow the `insert_method`: with `upsert` they are merged into
|
|
282
|
+
a single record, with `error_duplicate` the second occurrence fails with `Duplicate record found!`.
|
|
283
|
+
|
|
234
284
|
|
|
235
285
|
### Create Imports Controller:
|
|
236
286
|
```ruby
|
|
@@ -268,7 +318,24 @@ Headers of your csv file should be formatted/transformed to column names of your
|
|
|
268
318
|
|
|
269
319
|
## Development
|
|
270
320
|
|
|
271
|
-
After checking out the repo, run `bin/setup` to install dependencies.
|
|
321
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
|
322
|
+
|
|
323
|
+
The specs run against a real database. By default they use PostgreSQL
|
|
324
|
+
(database `active_record_importer_test`, created automatically if missing):
|
|
325
|
+
|
|
326
|
+
$ bundle exec rspec
|
|
327
|
+
$ DB=postgresql PG_HOST=localhost PG_USER=postgres PG_PASSWORD=secret bundle exec rspec
|
|
328
|
+
|
|
329
|
+
To run them against MySQL (uses the `trilogy` adapter, or `MYSQL_ADAPTER=mysql2`):
|
|
330
|
+
|
|
331
|
+
$ docker run -d --name ari_mysql -e MYSQL_ROOT_PASSWORD=root \
|
|
332
|
+
-e MYSQL_DATABASE=active_record_importer_test -p 3307:3306 mysql:8.0
|
|
333
|
+
$ DB=mysql MYSQL_PORT=3307 MYSQL_PASSWORD=root bundle exec rspec
|
|
334
|
+
|
|
335
|
+
Supported variables: `DB` (`postgresql`|`mysql`), `DB_NAME`, `PG_HOST`, `PG_PORT`, `PG_USER`,
|
|
336
|
+
`PG_PASSWORD`, `MYSQL_ADAPTER`, `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`.
|
|
337
|
+
|
|
338
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
272
339
|
|
|
273
340
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
274
341
|
|
|
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
spec.email = ['kapitan_03@yahoo.com']
|
|
11
11
|
|
|
12
12
|
spec.summary = 'Active Record Importer'
|
|
13
|
-
spec.description = 'Smart gem for importing
|
|
13
|
+
spec.description = 'Smart gem for importing CSV files to ActiveRecord models'
|
|
14
14
|
spec.homepage = 'https://github.com/michaelnera/active_record_importer'
|
|
15
15
|
spec.license = 'MIT'
|
|
16
16
|
|
|
@@ -19,10 +19,14 @@ Gem::Specification.new do |spec|
|
|
|
19
19
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
20
20
|
spec.require_paths = ['lib']
|
|
21
21
|
|
|
22
|
-
spec.
|
|
22
|
+
spec.required_ruby_version = '>= 2.7'
|
|
23
23
|
|
|
24
|
-
spec.
|
|
25
|
-
spec.
|
|
24
|
+
spec.add_development_dependency 'rake', '>= 12.0'
|
|
25
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
26
|
+
|
|
27
|
+
spec.add_runtime_dependency 'activerecord', '>= 6.0'
|
|
28
|
+
spec.add_runtime_dependency 'activerecord-import', '>= 1.0'
|
|
29
|
+
spec.add_runtime_dependency 'activesupport', '>= 6.0'
|
|
26
30
|
spec.add_runtime_dependency 'enumerize', '>= 2.0', '~> 2.0.1'
|
|
27
31
|
spec.add_runtime_dependency 'virtus', '>= 1.0', '~> 1.0'
|
|
28
32
|
spec.add_runtime_dependency 'smarter_csv', '>= 1.0', '~> 1.0'
|
|
@@ -1,65 +1,92 @@
|
|
|
1
1
|
module ActiveRecordImporter
|
|
2
|
+
##
|
|
3
|
+
# Imports one chunk of csv rows:
|
|
4
|
+
#
|
|
5
|
+
# 1. builds the attributes of every row
|
|
6
|
+
# 2. preloads, in one query, the records matched by the find options
|
|
7
|
+
# 3. builds and validates an instance for every row
|
|
8
|
+
# 4. persists the valid instances in bulk (see BatchPersister)
|
|
9
|
+
# 5. writes the failed rows (with their errors) and updates the counters
|
|
10
|
+
##
|
|
2
11
|
class BatchImporter
|
|
3
12
|
include Virtus.model
|
|
4
13
|
|
|
5
|
-
attribute :import, Import
|
|
6
14
|
attribute :importable
|
|
15
|
+
attribute :import
|
|
7
16
|
attribute :data, Array, default: []
|
|
8
|
-
attribute :failed_file, FailedFileBuilder, default: :initialize_failed_file
|
|
9
17
|
|
|
10
18
|
def process!
|
|
11
19
|
@imported_count, @failed_count = 0, 0
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
17
|
-
|
|
21
|
+
processors = build_processors
|
|
22
|
+
persist(processors)
|
|
23
|
+
record_results(processors)
|
|
18
24
|
set_import_count
|
|
19
|
-
finalize_batch_import
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
private
|
|
23
28
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
def build_processors
|
|
30
|
+
processors = rows.map { |row_attrs| build_processor(row_attrs) }
|
|
31
|
+
processors.each(&:prepare)
|
|
32
|
+
lookup.preload(processors.map(&:find_attributes_for_lookup))
|
|
33
|
+
processors.each(&:build)
|
|
34
|
+
processors
|
|
27
35
|
end
|
|
28
36
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import: import,
|
|
33
|
-
importable: importable,
|
|
34
|
-
row_attrs: row_attrs
|
|
35
|
-
)
|
|
36
|
-
return @imported_count += 1 if processor.process
|
|
37
|
+
def rows
|
|
38
|
+
data.reject(&:blank?).map { |row_attrs| row_attrs.to_hash.symbolize_keys }
|
|
39
|
+
end
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
def build_processor(row_attrs)
|
|
42
|
+
DataProcessor.new(
|
|
43
|
+
import: import,
|
|
44
|
+
importable: importable,
|
|
45
|
+
row_attrs: row_attrs,
|
|
46
|
+
lookup: lookup
|
|
47
|
+
)
|
|
40
48
|
end
|
|
41
49
|
|
|
42
|
-
def
|
|
43
|
-
|
|
50
|
+
def lookup
|
|
51
|
+
@lookup ||= RecordPreloader.new(importable)
|
|
52
|
+
end
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
def persist(processors)
|
|
55
|
+
BatchPersister.new(
|
|
56
|
+
importable: importable,
|
|
57
|
+
processors: processors.select(&:ready?)
|
|
58
|
+
).persist!
|
|
47
59
|
end
|
|
48
60
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
def record_results(processors)
|
|
62
|
+
processors.each do |processor|
|
|
63
|
+
if processor.failed?
|
|
64
|
+
write_failed_row(processor.row_attrs, processor.row_errors)
|
|
65
|
+
@failed_count += 1
|
|
66
|
+
else
|
|
67
|
+
@imported_count += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
52
70
|
end
|
|
53
71
|
|
|
54
|
-
def
|
|
55
|
-
return unless
|
|
56
|
-
|
|
72
|
+
def set_import_count
|
|
73
|
+
return unless import
|
|
74
|
+
|
|
75
|
+
import.class.update_counters(
|
|
76
|
+
import.id,
|
|
77
|
+
imported_rows: @imported_count,
|
|
78
|
+
failed_rows: @failed_count
|
|
79
|
+
)
|
|
57
80
|
end
|
|
58
81
|
|
|
59
|
-
|
|
82
|
+
def failed_file
|
|
83
|
+
return unless import
|
|
84
|
+
@failed_file ||= FailedFileBuilder.new(import)
|
|
85
|
+
end
|
|
60
86
|
|
|
61
|
-
def
|
|
62
|
-
|
|
87
|
+
def write_failed_row(row_attrs, errors)
|
|
88
|
+
return puts errors.inspect unless failed_file
|
|
89
|
+
failed_file.write(row_attrs.merge(import_errors: errors))
|
|
63
90
|
end
|
|
64
91
|
end
|
|
65
92
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module ActiveRecordImporter
|
|
2
|
+
##
|
|
3
|
+
# Persists the already validated instances of a chunk.
|
|
4
|
+
#
|
|
5
|
+
# When possible, new records are inserted with a single bulk INSERT and
|
|
6
|
+
# existing records are updated with a single bulk upsert on the primary
|
|
7
|
+
# key (through activerecord-import). If a bulk statement fails because of
|
|
8
|
+
# a database level error (e.g. a unique index), the affected group is
|
|
9
|
+
# retried row by row inside savepoints so only the offending rows fail.
|
|
10
|
+
#
|
|
11
|
+
# When importer callbacks need a persisted instance (after_save or
|
|
12
|
+
# state_machine_attr), or bulk insert is disabled for the model, every
|
|
13
|
+
# row is saved individually inside one transaction per chunk.
|
|
14
|
+
##
|
|
15
|
+
class BatchPersister
|
|
16
|
+
include Virtus.model
|
|
17
|
+
|
|
18
|
+
attribute :importable, Class
|
|
19
|
+
attribute :processors, Array, default: []
|
|
20
|
+
|
|
21
|
+
def persist!
|
|
22
|
+
return if processors.empty?
|
|
23
|
+
|
|
24
|
+
importable.transaction do
|
|
25
|
+
bulk? ? persist_in_bulk : persist_each(processors)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
delegate :importer_options, to: :importable
|
|
32
|
+
delegate :after_save, :state_machine_attr, :bulk_insert, to: :importer_options
|
|
33
|
+
|
|
34
|
+
def bulk?
|
|
35
|
+
bulk_insert != false && !callbacks_required? && bulk_import_supported?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def callbacks_required?
|
|
39
|
+
after_save.present? || state_machine_attr.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def bulk_import_supported?
|
|
43
|
+
importable.respond_to?(:bulk_import)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def bulk_update_supported?
|
|
47
|
+
connection = importable.connection
|
|
48
|
+
connection.respond_to?(:supports_on_duplicate_key_update?) &&
|
|
49
|
+
connection.supports_on_duplicate_key_update?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def persist_in_bulk
|
|
53
|
+
new_processors, existing_processors =
|
|
54
|
+
processors.partition { |processor| processor.instance.new_record? }
|
|
55
|
+
|
|
56
|
+
bulk_insert_records(new_processors)
|
|
57
|
+
bulk_update_records(existing_processors)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def bulk_insert_records(group)
|
|
61
|
+
return if group.empty?
|
|
62
|
+
bulk_import(group, validate: false)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bulk_update_records(group)
|
|
66
|
+
return if group.empty?
|
|
67
|
+
return persist_each(group) unless bulk_update_supported?
|
|
68
|
+
|
|
69
|
+
columns = changed_columns(group)
|
|
70
|
+
return if columns.empty?
|
|
71
|
+
|
|
72
|
+
bulk_import(group, validate: false, on_duplicate_key_update: columns)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def bulk_import(group, options)
|
|
76
|
+
instances = group.map(&:instance).uniq
|
|
77
|
+
|
|
78
|
+
importable.transaction(requires_new: true) do
|
|
79
|
+
importable.bulk_import(instances, options)
|
|
80
|
+
end
|
|
81
|
+
rescue ActiveRecord::ActiveRecordError
|
|
82
|
+
persist_each(group)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def changed_columns(group)
|
|
86
|
+
columns = group.flat_map { |processor| processor.instance.changed }.uniq
|
|
87
|
+
(columns - Array(importable.primary_key).map(&:to_s)).map(&:to_sym)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def persist_each(group)
|
|
91
|
+
group.each do |processor|
|
|
92
|
+
begin
|
|
93
|
+
importable.transaction(requires_new: true) do
|
|
94
|
+
processor.instance.save!
|
|
95
|
+
processor.after_persist
|
|
96
|
+
end
|
|
97
|
+
rescue => exception
|
|
98
|
+
processor.mark_failed(exception)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -1,44 +1,86 @@
|
|
|
1
1
|
module ActiveRecordImporter
|
|
2
|
+
##
|
|
3
|
+
# Prepares a single csv row: builds its attributes and find attributes,
|
|
4
|
+
# builds and validates the instance, and keeps the row errors.
|
|
5
|
+
# Persistence happens later, for the whole chunk, in BatchPersister.
|
|
6
|
+
##
|
|
2
7
|
class DataProcessor
|
|
3
8
|
include Virtus.model
|
|
4
9
|
|
|
5
|
-
attribute :import, Import
|
|
6
10
|
attribute :importable, Class
|
|
11
|
+
attribute :import
|
|
7
12
|
attribute :insert_method, String, default: :set_insert_method
|
|
8
13
|
attribute :row_attrs, Hash
|
|
9
14
|
attribute :instance_attrs, Hash
|
|
10
15
|
attribute :find_options, String, default: :set_find_options
|
|
11
16
|
attribute :find_attributes, Hash
|
|
12
|
-
attribute :row_errors,
|
|
17
|
+
attribute :row_errors, String
|
|
13
18
|
attribute :instance
|
|
19
|
+
attribute :lookup, Object, default: :default_lookup
|
|
14
20
|
|
|
15
21
|
def process
|
|
22
|
+
prepare
|
|
23
|
+
build
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Step 1: build the attributes used for the instance and the lookup
|
|
28
|
+
##
|
|
29
|
+
def prepare
|
|
16
30
|
fetch_instance_attributes
|
|
17
31
|
fetch_find_attributes
|
|
18
|
-
|
|
32
|
+
!failed?
|
|
19
33
|
end
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
##
|
|
36
|
+
# Step 2: build and validate the instance
|
|
37
|
+
##
|
|
38
|
+
def build
|
|
39
|
+
return false if failed?
|
|
40
|
+
|
|
41
|
+
@instance =
|
|
42
|
+
InstanceBuilder.new(
|
|
43
|
+
importable: importable,
|
|
44
|
+
insert_method: insert_method,
|
|
45
|
+
find_attributes: find_attributes,
|
|
46
|
+
instance_attrs: attributes_without_state_machine_attrs,
|
|
47
|
+
lookup: lookup
|
|
48
|
+
).build
|
|
49
|
+
true
|
|
50
|
+
rescue => exception
|
|
51
|
+
append_errors(exception)
|
|
52
|
+
false
|
|
53
|
+
end
|
|
22
54
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
##
|
|
56
|
+
# Find attributes that need to be preloaded for this row
|
|
57
|
+
##
|
|
58
|
+
def find_attributes_for_lookup
|
|
59
|
+
return if failed? || insert_method.inquiry.insert?
|
|
60
|
+
find_attributes
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# Called once the instance is persisted (per-row mode only)
|
|
65
|
+
##
|
|
66
|
+
def after_persist
|
|
67
|
+
methods_after_upsert
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def mark_failed(error)
|
|
71
|
+
append_errors(error)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def failed?
|
|
75
|
+
row_errors.present?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ready?
|
|
79
|
+
!failed? && instance.present?
|
|
40
80
|
end
|
|
41
81
|
|
|
82
|
+
private
|
|
83
|
+
|
|
42
84
|
def fetch_instance_attributes
|
|
43
85
|
@instance_attrs = Attribute::AttributesBuilder.new(
|
|
44
86
|
importable, row_attrs
|
|
@@ -48,6 +90,8 @@ module ActiveRecordImporter
|
|
|
48
90
|
end
|
|
49
91
|
|
|
50
92
|
def fetch_find_attributes
|
|
93
|
+
return if failed?
|
|
94
|
+
|
|
51
95
|
@find_attributes = Attribute::FindOptionsBuilder.new(
|
|
52
96
|
importable: importable,
|
|
53
97
|
find_options: find_options,
|
|
@@ -81,7 +125,7 @@ module ActiveRecordImporter
|
|
|
81
125
|
end
|
|
82
126
|
|
|
83
127
|
def attributes_without_state_machine_attrs
|
|
84
|
-
instance_attrs.except(*state_machine_attr)
|
|
128
|
+
instance_attrs.except(*Array(state_machine_attr))
|
|
85
129
|
end
|
|
86
130
|
|
|
87
131
|
def skip_callbacks?
|
|
@@ -94,12 +138,10 @@ module ActiveRecordImporter
|
|
|
94
138
|
ImportCallbacker.new(instance, after_save).call
|
|
95
139
|
end
|
|
96
140
|
|
|
97
|
-
def append_errors(error
|
|
141
|
+
def append_errors(error)
|
|
98
142
|
return if error.blank?
|
|
99
143
|
|
|
100
|
-
|
|
101
|
-
@row_errors = message
|
|
102
|
-
fail ActiveRecord::Rollback if rollback
|
|
144
|
+
@row_errors = error.is_a?(Exception) ? error.message : error.to_s
|
|
103
145
|
end
|
|
104
146
|
|
|
105
147
|
def set_insert_method
|
|
@@ -111,5 +153,9 @@ module ActiveRecordImporter
|
|
|
111
153
|
def set_find_options
|
|
112
154
|
import.try(:find_options) || importer_options.find_options.join(',')
|
|
113
155
|
end
|
|
156
|
+
|
|
157
|
+
def default_lookup
|
|
158
|
+
RecordPreloader.new(importable)
|
|
159
|
+
end
|
|
114
160
|
end
|
|
115
161
|
end
|
|
@@ -2,13 +2,14 @@ module ActiveRecordImporter
|
|
|
2
2
|
class Dispatcher
|
|
3
3
|
include Virtus.model
|
|
4
4
|
|
|
5
|
-
attribute :import, Import
|
|
6
5
|
attribute :importable, Class
|
|
7
6
|
attribute :execute, Boolean, default: true
|
|
7
|
+
attribute :import
|
|
8
8
|
attribute :import_file
|
|
9
9
|
|
|
10
10
|
def call
|
|
11
11
|
divide_and_conquer
|
|
12
|
+
create_import_failed_file
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
private
|
|
@@ -19,13 +20,14 @@ module ActiveRecordImporter
|
|
|
19
20
|
queue_or_execute(collection)
|
|
20
21
|
end
|
|
21
22
|
end
|
|
23
|
+
|
|
22
24
|
true
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def csv_options
|
|
26
28
|
opts = klass_csv_opts
|
|
27
|
-
return opts if import.nil? || import.batch_size.
|
|
28
|
-
opts.merge(chunk_size: import.batch_size)
|
|
29
|
+
return opts if import.nil? || import.batch_size.to_i <= 0
|
|
30
|
+
opts.merge(chunk_size: import.batch_size.to_i)
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def klass_csv_opts
|
|
@@ -45,8 +47,36 @@ module ActiveRecordImporter
|
|
|
45
47
|
).process!
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
def queue(
|
|
50
|
+
def queue(_collection)
|
|
49
51
|
# To follow
|
|
50
52
|
end
|
|
53
|
+
|
|
54
|
+
def create_import_failed_file
|
|
55
|
+
return if import.nil? || !File.exist?(temp_failed_file_path)
|
|
56
|
+
File.open(temp_failed_file_path) do |file|
|
|
57
|
+
import.failed_file = file
|
|
58
|
+
|
|
59
|
+
# I forced to save it as 'text/csv' because
|
|
60
|
+
# the file is being saved as 'text/x-pascal'
|
|
61
|
+
# and I still have no idea why?!?
|
|
62
|
+
|
|
63
|
+
import.failed_file_content_type = 'text/csv'
|
|
64
|
+
import.save!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
destroy_temp_failed_file
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def destroy_temp_failed_file
|
|
71
|
+
FileUtils.rm(temp_failed_file_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def temp_failed_file_path
|
|
75
|
+
"/tmp/#{target_file_name}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def target_file_name
|
|
79
|
+
"failed_file_#{import.id}.csv"
|
|
80
|
+
end
|
|
51
81
|
end
|
|
52
82
|
end
|