importu 0.1.0 → 0.2.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/.editorconfig +15 -0
- data/.github/workflows/ci.yml +48 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/.rubocop.yml +311 -0
- data/.simplecov +14 -0
- data/.yardstick.yml +36 -0
- data/Appraisals +22 -0
- data/CHANGELOG.md +51 -0
- data/CONTRIBUTING.md +86 -0
- data/Gemfile +5 -1
- data/LICENSE +21 -0
- data/README.md +435 -52
- data/Rakefile +71 -0
- data/UPGRADING.md +188 -0
- data/gemfiles/rails_7_2.gemfile +11 -0
- data/gemfiles/rails_7_2.gemfile.lock +268 -0
- data/gemfiles/rails_8_0.gemfile +11 -0
- data/gemfiles/rails_8_0.gemfile.lock +271 -0
- data/gemfiles/rails_8_1.gemfile +11 -0
- data/gemfiles/rails_8_1.gemfile.lock +269 -0
- data/gemfiles/standalone.gemfile +8 -0
- data/gemfiles/standalone.gemfile.lock +197 -0
- data/importu.gemspec +41 -22
- data/lib/importu/backends/active_record.rb +171 -0
- data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
- data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
- data/lib/importu/backends/middleware.rb +11 -0
- data/lib/importu/backends.rb +103 -0
- data/lib/importu/config_dsl.rb +381 -0
- data/lib/importu/converter_context.rb +94 -0
- data/lib/importu/converters.rb +119 -64
- data/lib/importu/definition.rb +23 -0
- data/lib/importu/duplicate_manager.rb +88 -0
- data/lib/importu/exceptions.rb +135 -4
- data/lib/importu/importer.rb +183 -96
- data/lib/importu/record.rb +138 -102
- data/lib/importu/sources/csv.rb +122 -0
- data/lib/importu/sources/json.rb +106 -0
- data/lib/importu/sources/ruby.rb +46 -0
- data/lib/importu/sources/xml.rb +133 -0
- data/lib/importu/sources.rb +13 -0
- data/lib/importu/summary.rb +277 -0
- data/lib/importu/version.rb +3 -1
- data/lib/importu.rb +45 -9
- data/spec/fixtures/books-duplicates/README.md +7 -0
- data/spec/fixtures/books-duplicates/infile.csv +7 -0
- data/spec/fixtures/books-duplicates/model.json +23 -0
- data/spec/fixtures/books-duplicates/summary.json +10 -0
- data/spec/fixtures/books-valid/README.md +13 -0
- data/spec/fixtures/books-valid/infile.csv +4 -0
- data/spec/fixtures/books-valid/infile.json +23 -0
- data/spec/fixtures/books-valid/infile.xml +21 -0
- data/spec/fixtures/books-valid/model.json +23 -0
- data/spec/fixtures/books-valid/record.json +26 -0
- data/spec/fixtures/books-valid/summary.json +8 -0
- data/spec/fixtures/source-empty-file/infile.csv +0 -0
- data/spec/fixtures/source-empty-file/infile.json +0 -0
- data/spec/fixtures/source-empty-file/infile.xml +0 -0
- data/spec/fixtures/source-empty-records/infile.csv +3 -0
- data/spec/fixtures/source-empty-records/infile.json +1 -0
- data/spec/fixtures/source-empty-records/infile.xml +6 -0
- data/spec/fixtures/source-malformed/infile.csv +1 -0
- data/spec/fixtures/source-malformed/infile.json +1 -0
- data/spec/fixtures/source-malformed/infile.xml +3 -0
- data/spec/fixtures/source-no-records/infile.csv +1 -0
- data/spec/fixtures/source-no-records/infile.json +1 -0
- data/spec/fixtures/source-no-records/infile.xml +3 -0
- data/spec/lib/importu/backends/active_record_spec.rb +150 -0
- data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
- data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
- data/spec/lib/importu/backends_spec.rb +170 -0
- data/spec/lib/importu/converters_spec.rb +184 -141
- data/spec/lib/importu/definition_spec.rb +248 -0
- data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
- data/spec/lib/importu/exceptions_spec.rb +69 -16
- data/spec/lib/importu/import_context_spec.rb +199 -0
- data/spec/lib/importu/importer_spec.rb +95 -0
- data/spec/lib/importu/integration_spec.rb +221 -0
- data/spec/lib/importu/record_spec.rb +130 -80
- data/spec/lib/importu/sources/csv_spec.rb +29 -0
- data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
- data/spec/lib/importu/sources/json_spec.rb +29 -0
- data/spec/lib/importu/sources/ruby_spec.rb +102 -0
- data/spec/lib/importu/sources/xml_spec.rb +70 -0
- data/spec/lib/importu/summary_spec.rb +186 -0
- data/spec/spec_helper.rb +91 -7
- data/spec/support/active_record.rb +20 -0
- data/spec/support/book_importer.rb +31 -0
- data/spec/support/dummy_backend.rb +50 -0
- data/spec/support/fixtures_helper.rb +43 -0
- data/spec/support/matchers/delegate_matcher.rb +14 -8
- metadata +173 -100
- data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
- data/lib/importu/core_ext/deep_freeze.rb +0 -3
- data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
- data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
- data/lib/importu/core_ext.rb +0 -3
- data/lib/importu/dsl.rb +0 -127
- data/lib/importu/importer/csv.rb +0 -52
- data/lib/importu/importer/json.rb +0 -45
- data/lib/importu/importer/xml.rb +0 -55
- data/spec/factories/importer.rb +0 -12
- data/spec/factories/importer_record.rb +0 -13
- data/spec/factories/json_importer.rb +0 -14
- data/spec/factories/xml_importer.rb +0 -12
- data/spec/lib/importu/dsl_spec.rb +0 -26
- data/spec/lib/importu/importer/json_spec.rb +0 -37
- data/spec/lib/importu/importer/xml_spec.rb +0 -14
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "spec_helper"
|
|
3
|
+
|
|
4
|
+
require "importu/definition"
|
|
5
|
+
require "importu/importer"
|
|
6
|
+
require "importu/sources/ruby"
|
|
7
|
+
|
|
8
|
+
RSpec.describe Importu::Importer do
|
|
9
|
+
subject(:importer) { importer_class.new(source) }
|
|
10
|
+
|
|
11
|
+
let(:source) { Importu::Sources::Ruby.new(data) }
|
|
12
|
+
|
|
13
|
+
let(:data) do
|
|
14
|
+
[
|
|
15
|
+
{ "animal" => "llama", "name" => "Nathan", "age" => "3" },
|
|
16
|
+
{ "animal" => "aardvark", "name" => "Stella", "age" => "2" },
|
|
17
|
+
{ "animal" => "crow", "name" => "Hamilton", "age" => "6" },
|
|
18
|
+
]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
let(:importer_class) do
|
|
22
|
+
Class.new(Importu::Importer) do
|
|
23
|
+
fields :animal, :name, :age, required: true
|
|
24
|
+
field :age, &convert_to(:integer)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe "#config" do
|
|
29
|
+
it "returns the configuration of the import definition" do
|
|
30
|
+
expect(importer.config[:fields]).to include(:animal, :name, :age)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context "when a definition is specified at initialization" do
|
|
34
|
+
subject(:importer) { importer_class.new(source, definition: definition) }
|
|
35
|
+
let(:definition) do
|
|
36
|
+
Class.new(Importu::Definition) { fields :species, :extinction_date }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "uses the definition from initialization" do
|
|
40
|
+
expect(importer.config[:fields]).to include(:species, :extinction_date)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe "#import!" do
|
|
46
|
+
context "when a backend is specified at initialization" do
|
|
47
|
+
subject(:importer) { importer_class.new(source, backend: backend) }
|
|
48
|
+
let(:backend) { DummyBackend.new(**importer_class.config[:backend]) }
|
|
49
|
+
|
|
50
|
+
it "uses the backend from initialization" do
|
|
51
|
+
expect(Importu::Importer).to_not receive(:backend_registry)
|
|
52
|
+
expect(backend).to receive(:create).exactly(3).times.and_call_original
|
|
53
|
+
importer.import!
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context "when a backend is not specified at initialization" do
|
|
58
|
+
it "tries to detect backend from the definition" do
|
|
59
|
+
expect(Importu::Importer.backend_registry)
|
|
60
|
+
.to receive(:from_config!).with(importer.config[:backend])
|
|
61
|
+
.and_return(DummyBackend)
|
|
62
|
+
importer.import!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context "when a custom recorder/summarizer is specified" do
|
|
67
|
+
subject(:importer) { importer_class.new(source, backend: backend) }
|
|
68
|
+
let(:backend) { DummyBackend.new(**importer_class.config[:backend]) }
|
|
69
|
+
|
|
70
|
+
it "records status of each record import" do
|
|
71
|
+
recorder = instance_double("Importu::Summary")
|
|
72
|
+
expect(recorder).to receive(:record).exactly(3).times
|
|
73
|
+
|
|
74
|
+
importer.import!(recorder)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe "#records" do
|
|
80
|
+
it "returns record objects with conversions applied" do
|
|
81
|
+
expect(importer.records.map(&:to_hash)).to eq([
|
|
82
|
+
{ animal: "llama", name: "Nathan", age: 3 },
|
|
83
|
+
{ animal: "aardvark", name: "Stella", age: 2 },
|
|
84
|
+
{ animal: "crow", name: "Hamilton", age: 6 },
|
|
85
|
+
])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe "#source" do
|
|
90
|
+
it "returns the source provided to importer at initialization" do
|
|
91
|
+
expect(importer.source).to eq source
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
require "importu/backends/active_record"
|
|
6
|
+
require "importu/importer"
|
|
7
|
+
require "importu/sources/csv"
|
|
8
|
+
require "importu/sources/json"
|
|
9
|
+
require "importu/sources/xml"
|
|
10
|
+
|
|
11
|
+
# Integration tests that exercise the full import pipeline across different
|
|
12
|
+
# source formats and scenarios. These serve as smoke tests and living
|
|
13
|
+
# documentation of the gem's primary use cases.
|
|
14
|
+
RSpec.describe "Integration", :active_record do
|
|
15
|
+
let!(:model) do
|
|
16
|
+
stub_const("Book", Class.new(ActiveRecord::Base) do
|
|
17
|
+
serialize :authors, type: Array
|
|
18
|
+
validates :title, :authors, :isbn10, :release_date, presence: true
|
|
19
|
+
validates :isbn10, length: { is: 10 }, uniqueness: true
|
|
20
|
+
end)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:importer_class) do
|
|
24
|
+
Class.new(BookImporter) do
|
|
25
|
+
model "Book"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
around(:each) do |example|
|
|
30
|
+
require "database_cleaner-active_record"
|
|
31
|
+
DatabaseCleaner.cleaning { example.run }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe "JSON import updating existing records" do
|
|
35
|
+
let(:source) { Importu::Sources::JSON.new(infile("books-valid", :json)) }
|
|
36
|
+
subject(:importer) { importer_class.new(source) }
|
|
37
|
+
|
|
38
|
+
context "when records already exist" do
|
|
39
|
+
before { importer.import! }
|
|
40
|
+
|
|
41
|
+
it "finds and updates existing records" do
|
|
42
|
+
summary = importer.import!
|
|
43
|
+
expect(summary.created).to eq 0
|
|
44
|
+
expect(summary.updated).to eq 0
|
|
45
|
+
expect(summary.unchanged).to eq 3
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context "when data has changed" do
|
|
49
|
+
let(:modified_source) do
|
|
50
|
+
data = JSON.parse(File.read(infile("books-valid", :json)))
|
|
51
|
+
data[0]["title"] = "The Ruby Programming Language (2nd Edition)"
|
|
52
|
+
Importu::Sources::JSON.new(StringIO.new(JSON.generate(data)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "updates the changed record" do
|
|
56
|
+
modified_importer = importer_class.new(modified_source)
|
|
57
|
+
summary = modified_importer.import!
|
|
58
|
+
|
|
59
|
+
expect(summary.created).to eq 0
|
|
60
|
+
expect(summary.updated).to eq 1
|
|
61
|
+
expect(summary.unchanged).to eq 2
|
|
62
|
+
|
|
63
|
+
updated_book = Book.find_by(isbn10: "0596516177")
|
|
64
|
+
expect(updated_book.title).to eq "The Ruby Programming Language (2nd Edition)"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe "XML import with duplicate detection" do
|
|
71
|
+
let(:source_config) do
|
|
72
|
+
Class.new(Importu::Definition) { source :xml, records_xpath: "//book" }
|
|
73
|
+
.config[:sources][:xml]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
let(:xml_with_duplicates) do
|
|
77
|
+
<<~XML
|
|
78
|
+
<?xml version="1.0"?>
|
|
79
|
+
<books>
|
|
80
|
+
<book isbn10="0596516177" title="First" author="Author A" release_date="2008-01-01" pages="100" />
|
|
81
|
+
<book isbn10="1449355978" title="Second" author="Author B" release_date="2013-05-01" pages="200" />
|
|
82
|
+
<book isbn10="0596516177" title="Duplicate" author="Author C" release_date="2010-01-01" pages="300" />
|
|
83
|
+
</books>
|
|
84
|
+
XML
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
let(:source) { Importu::Sources::XML.new(StringIO.new(xml_with_duplicates), **source_config) }
|
|
88
|
+
subject(:importer) { importer_class.new(source) }
|
|
89
|
+
|
|
90
|
+
it "creates the first record and marks duplicate as invalid" do
|
|
91
|
+
summary = importer.import!
|
|
92
|
+
|
|
93
|
+
expect(summary.created).to eq 2
|
|
94
|
+
expect(summary.invalid).to eq 1
|
|
95
|
+
expect(summary.validation_errors.keys).to include(match(/duplicate/i))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe "mixed create/update with error reporting" do
|
|
100
|
+
let(:source) { Importu::Sources::CSV.new(infile("books-valid", :csv)) }
|
|
101
|
+
subject(:importer) { importer_class.new(source) }
|
|
102
|
+
|
|
103
|
+
context "when some records exist and others are new" do
|
|
104
|
+
before do
|
|
105
|
+
# Create only the first book
|
|
106
|
+
Book.create!(
|
|
107
|
+
isbn10: "0596516177",
|
|
108
|
+
title: "Old Title",
|
|
109
|
+
authors: ["Old Author"],
|
|
110
|
+
release_date: Date.new(2008, 1, 1),
|
|
111
|
+
pages: 100
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "creates new records and updates existing ones" do
|
|
116
|
+
summary = importer.import!
|
|
117
|
+
|
|
118
|
+
expect(summary.created).to eq 2
|
|
119
|
+
expect(summary.updated).to eq 1
|
|
120
|
+
expect(summary.unchanged).to eq 0
|
|
121
|
+
expect(summary.invalid).to eq 0
|
|
122
|
+
|
|
123
|
+
expect(Book.count).to eq 3
|
|
124
|
+
updated_book = Book.find_by(isbn10: "0596516177")
|
|
125
|
+
expect(updated_book.title).to eq "The Ruby Programming Language"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
context "when some records fail validation" do
|
|
130
|
+
let(:importer_class) do
|
|
131
|
+
Class.new(super()) do
|
|
132
|
+
# Force first record to have invalid ISBN (apply trimmed first to
|
|
133
|
+
# handle whitespace in source data)
|
|
134
|
+
field :isbn10 do
|
|
135
|
+
value = trimmed(:isbn10)
|
|
136
|
+
value == "0596516177" ? "bad" : value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "reports validation errors while processing valid records" do
|
|
142
|
+
summary = importer.import!
|
|
143
|
+
|
|
144
|
+
expect(summary.created).to eq 2
|
|
145
|
+
expect(summary.invalid).to eq 1
|
|
146
|
+
expect(summary.validation_errors.keys).to include(match(/isbn10/i))
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
context "when actions are restricted" do
|
|
151
|
+
context "with only create allowed" do
|
|
152
|
+
let(:importer_class) do
|
|
153
|
+
Class.new(super()) { allow_actions :create }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
before do
|
|
157
|
+
Book.create!(
|
|
158
|
+
isbn10: "0596516177",
|
|
159
|
+
title: "Existing",
|
|
160
|
+
authors: ["Author"],
|
|
161
|
+
release_date: Date.new(2008, 1, 1)
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "creates new records but marks updates as invalid" do
|
|
166
|
+
summary = importer.import!
|
|
167
|
+
|
|
168
|
+
expect(summary.created).to eq 2
|
|
169
|
+
expect(summary.invalid).to eq 1
|
|
170
|
+
expect(summary.validation_errors.keys).to include(match(/update.*not allowed/i))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
context "with only update allowed" do
|
|
175
|
+
let(:importer_class) do
|
|
176
|
+
Class.new(super()) { allow_actions :update }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
before do
|
|
180
|
+
Book.create!(
|
|
181
|
+
isbn10: "0596516177",
|
|
182
|
+
title: "Existing",
|
|
183
|
+
authors: ["Author"],
|
|
184
|
+
release_date: Date.new(2008, 1, 1)
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "updates existing records but marks creates as invalid" do
|
|
189
|
+
summary = importer.import!
|
|
190
|
+
|
|
191
|
+
expect(summary.created).to eq 0
|
|
192
|
+
expect(summary.updated).to eq 1
|
|
193
|
+
expect(summary.invalid).to eq 2
|
|
194
|
+
expect(summary.validation_errors.keys).to include(match(/create.*not allowed/i))
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe "import with before_save hooks" do
|
|
201
|
+
let(:source) { Importu::Sources::JSON.new(infile("books-valid", :json)) }
|
|
202
|
+
|
|
203
|
+
let(:importer_class) do
|
|
204
|
+
Class.new(super()) do
|
|
205
|
+
before_save do
|
|
206
|
+
object.title = object.title.upcase
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
subject(:importer) { importer_class.new(source) }
|
|
212
|
+
|
|
213
|
+
it "applies the hook to all records" do
|
|
214
|
+
importer.import!
|
|
215
|
+
|
|
216
|
+
Book.all.each do |book|
|
|
217
|
+
expect(book.title).to eq book.title.upcase
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -1,123 +1,173 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "spec_helper"
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
require "importu/converter_context"
|
|
5
|
+
require "importu/definition"
|
|
6
|
+
require "importu/record"
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
RSpec.describe Importu::Record do
|
|
9
|
+
subject(:record) { Importu::Record.new(data, context, **definition.config) }
|
|
10
|
+
let(:context) { Importu::ConverterContext.with_config(**definition.config) }
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
let(:definition) do
|
|
13
|
+
Class.new(Importu::Definition) do
|
|
14
|
+
field :pilot
|
|
15
|
+
field :balloons, &convert_to(:integer)
|
|
16
|
+
field :flying, &convert_to(:boolean)
|
|
15
17
|
end
|
|
18
|
+
end
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
let(:data) { { "pilot" => "Nena", "balloons" => "99", "flying" => "yes" } }
|
|
21
|
+
|
|
22
|
+
it "supports hash-like accessors" do
|
|
23
|
+
expect(record[:pilot]).to eq "Nena"
|
|
24
|
+
expect(record.fetch(:balloons)).to eq 99
|
|
25
|
+
expect { record.fetch(:color) }.to raise_error(KeyError)
|
|
26
|
+
expect(record.key?(:flying)).to be true
|
|
27
|
+
expect(record.key?(:color)).to be false
|
|
28
|
+
expect(record.keys).to eq [:pilot, :balloons, :flying]
|
|
29
|
+
expect(record.values).to eq ["Nena", 99, true]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "supports enumerable behaviors" do
|
|
33
|
+
expect(record.each.with_index.to_a) # enumerable is composable (#w_index)
|
|
34
|
+
.to eq [[[:pilot, "Nena"], 0], [[:balloons, 99], 1], [[:flying, true], 2]]
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
expect(record.reduce([]) {|acc, (k, _)| acc << k }).to eq record.keys
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe "#assignable_fields_for" do
|
|
40
|
+
it "returns all field names by default" do
|
|
41
|
+
expect(record.assignable_fields_for(:create))
|
|
42
|
+
.to eq [:pilot, :balloons, :flying]
|
|
23
43
|
end
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
context "when a field is allowed for create but not update" do
|
|
46
|
+
let(:definition) do
|
|
47
|
+
Class.new(super()) { field :pilot, create: true, update: false }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "includes field for :create action but not :update action" do
|
|
51
|
+
expect(record.assignable_fields_for(:create)).to include(:pilot)
|
|
52
|
+
expect(record.assignable_fields_for(:update)).to_not include(:pilot)
|
|
53
|
+
end
|
|
27
54
|
end
|
|
28
55
|
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
context "when a field is marked as abstract" do
|
|
57
|
+
let(:definition) do
|
|
58
|
+
Class.new(super()) { field :balloons, abstract: true }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "excludes the abstract field from list" do
|
|
62
|
+
expect(record.assignable_fields_for(:create)).to eq [:pilot, :flying]
|
|
63
|
+
expect(record.assignable_fields_for(:update)).to eq [:pilot, :flying]
|
|
64
|
+
end
|
|
31
65
|
end
|
|
32
66
|
end
|
|
33
67
|
|
|
34
68
|
describe "#data" do
|
|
35
|
-
it "returns
|
|
36
|
-
data
|
|
37
|
-
record = build(:importer_record, :data => data)
|
|
38
|
-
record.data.should == data
|
|
69
|
+
it "returns data supplied during initialization" do
|
|
70
|
+
expect(record.data).to eq data
|
|
39
71
|
end
|
|
40
72
|
end
|
|
41
73
|
|
|
42
|
-
describe "#
|
|
43
|
-
it "returns
|
|
44
|
-
|
|
45
|
-
record = build(:importer_record, :raw_data => raw_data)
|
|
46
|
-
record.raw_data.should == raw_data
|
|
74
|
+
describe "#errors" do
|
|
75
|
+
it "returns []" do
|
|
76
|
+
expect(record.errors).to eq []
|
|
47
77
|
end
|
|
48
|
-
end
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
context "when one or more values could not be converted" do
|
|
80
|
+
let(:data) { super().merge!("balloons" => "many", "flying" => "maybe") }
|
|
81
|
+
|
|
82
|
+
it "returns list of field parse errors for failed conversions" do
|
|
83
|
+
expect(record.errors.map(&:field_name))
|
|
84
|
+
.to match_array([:balloons, :flying])
|
|
85
|
+
end
|
|
56
86
|
end
|
|
57
87
|
end
|
|
58
88
|
|
|
59
|
-
describe "#
|
|
60
|
-
it "
|
|
61
|
-
|
|
62
|
-
record.should_receive(:generate_record_hash).and_return(expected)
|
|
63
|
-
record.record_hash.should eq expected
|
|
89
|
+
describe "#to_hash" do
|
|
90
|
+
it "returns data with field converters applied" do
|
|
91
|
+
expect(record.to_hash).to eq(pilot: "Nena", balloons: 99, flying: true)
|
|
64
92
|
end
|
|
65
93
|
|
|
66
|
-
it "
|
|
67
|
-
|
|
68
|
-
record.should_receive(:generate_record_hash).once.and_return(expected)
|
|
69
|
-
record.record_hash
|
|
70
|
-
record.record_hash.should eq expected
|
|
94
|
+
it "does not try to recovert the data each time (returns same has)" do
|
|
95
|
+
expect(record.to_hash.object_id).to eq record.to_hash.object_id
|
|
71
96
|
end
|
|
72
97
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
record.to_hash.should == :called
|
|
76
|
-
end
|
|
98
|
+
context "when one or more values could not be converted" do
|
|
99
|
+
let(:data) { super().merge!("flying" => "maybe") }
|
|
77
100
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
end
|
|
101
|
+
it "raises an InvalidRecord error with errors from field conversion" do
|
|
102
|
+
expect { record.to_hash }.to raise_error(Importu::InvalidRecord)
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
begin
|
|
105
|
+
record.to_hash
|
|
106
|
+
rescue Importu::InvalidRecord => e
|
|
107
|
+
expect(e.validation_errors.count).to eq 1
|
|
108
|
+
expect(e.validation_errors.first.field_name).to eq :flying
|
|
109
|
+
end
|
|
110
|
+
end
|
|
84
111
|
end
|
|
112
|
+
end
|
|
85
113
|
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
describe "#valid?" do
|
|
115
|
+
context "when all values can be converted successfully" do
|
|
116
|
+
it "returns true" do
|
|
117
|
+
expect(record).to be_valid
|
|
118
|
+
end
|
|
88
119
|
end
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
121
|
+
context "when one or more values could not be converted" do
|
|
122
|
+
let(:data) { super().merge!("flying" => "maybe") }
|
|
123
|
+
|
|
124
|
+
it "returns false" do
|
|
125
|
+
expect(record).to_not be_valid
|
|
126
|
+
end
|
|
92
127
|
end
|
|
128
|
+
end
|
|
93
129
|
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RSpec.describe Importu::Record::Iterator do
|
|
133
|
+
subject(:iterator) { described_class.new(rows, **definition.config) }
|
|
134
|
+
|
|
135
|
+
let(:definition) do
|
|
136
|
+
Class.new(Importu::Definition) do
|
|
137
|
+
fields :animal, :name, :age, required: true
|
|
138
|
+
field :age, &convert_to(:integer)
|
|
96
139
|
end
|
|
140
|
+
end
|
|
97
141
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
let(:rows) do
|
|
143
|
+
[
|
|
144
|
+
{ "animal" => "llama", "name" => "Nathan", "age" => "3" },
|
|
145
|
+
{ "animal" => "aardvark", "name" => "Stella", "age" => "2" },
|
|
146
|
+
{ "animal" => "crow", "name" => "Hamilton", "age" => "6" },
|
|
147
|
+
]
|
|
148
|
+
end
|
|
104
149
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
end
|
|
150
|
+
it "returns the same number of records as source data" do
|
|
151
|
+
expect(iterator.count).to eq 3
|
|
152
|
+
end
|
|
109
153
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
154
|
+
it "returns record objects with conversions applied" do
|
|
155
|
+
expect(iterator.first.to_hash).to eq({
|
|
156
|
+
animal: "llama", name: "Nathan", age: 3
|
|
157
|
+
})
|
|
158
|
+
end
|
|
114
159
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
160
|
+
context "when one of the records is invalid" do
|
|
161
|
+
before { rows.first["age"] = "old" }
|
|
162
|
+
|
|
163
|
+
it "returns the invalid record" do
|
|
164
|
+
expect(iterator.first).to_not be_valid
|
|
120
165
|
end
|
|
121
166
|
|
|
167
|
+
it "raises an exception when converted record value is accessed" do
|
|
168
|
+
expect { iterator.first["animal"] }
|
|
169
|
+
.to raise_error(Importu::InvalidRecord)
|
|
170
|
+
end
|
|
122
171
|
end
|
|
172
|
+
|
|
123
173
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "spec_helper"
|
|
3
|
+
|
|
4
|
+
require "importu/definition"
|
|
5
|
+
require "importu/sources/csv"
|
|
6
|
+
|
|
7
|
+
require_relative "importer_source_examples"
|
|
8
|
+
|
|
9
|
+
RSpec.describe Importu::Sources::CSV do
|
|
10
|
+
it_behaves_like "importer source", :csv do
|
|
11
|
+
subject(:source) { described_class.new(input, **source_config) }
|
|
12
|
+
let(:definition) { Class.new(Importu::Definition) }
|
|
13
|
+
let(:source_config) { definition.config[:sources][:csv] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe "#initialize" do
|
|
17
|
+
context "with custom csv options" do
|
|
18
|
+
let(:csv_options) { { skip_blanks: false } }
|
|
19
|
+
let(:data) { "foo\n\n\n" }
|
|
20
|
+
|
|
21
|
+
it "allows overriding csv options" do
|
|
22
|
+
original_source = described_class.new(StringIO.new(data))
|
|
23
|
+
source = described_class.new(StringIO.new(data), csv_options: csv_options)
|
|
24
|
+
expect(source.rows.count).to be > original_source.rows.count
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
end
|