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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +15 -0
  3. data/.github/workflows/ci.yml +48 -0
  4. data/.gitignore +4 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +311 -0
  7. data/.simplecov +14 -0
  8. data/.yardstick.yml +36 -0
  9. data/Appraisals +22 -0
  10. data/CHANGELOG.md +51 -0
  11. data/CONTRIBUTING.md +86 -0
  12. data/Gemfile +5 -1
  13. data/LICENSE +21 -0
  14. data/README.md +435 -52
  15. data/Rakefile +71 -0
  16. data/UPGRADING.md +188 -0
  17. data/gemfiles/rails_7_2.gemfile +11 -0
  18. data/gemfiles/rails_7_2.gemfile.lock +268 -0
  19. data/gemfiles/rails_8_0.gemfile +11 -0
  20. data/gemfiles/rails_8_0.gemfile.lock +271 -0
  21. data/gemfiles/rails_8_1.gemfile +11 -0
  22. data/gemfiles/rails_8_1.gemfile.lock +269 -0
  23. data/gemfiles/standalone.gemfile +8 -0
  24. data/gemfiles/standalone.gemfile.lock +197 -0
  25. data/importu.gemspec +41 -22
  26. data/lib/importu/backends/active_record.rb +171 -0
  27. data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
  28. data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
  29. data/lib/importu/backends/middleware.rb +11 -0
  30. data/lib/importu/backends.rb +103 -0
  31. data/lib/importu/config_dsl.rb +381 -0
  32. data/lib/importu/converter_context.rb +94 -0
  33. data/lib/importu/converters.rb +119 -64
  34. data/lib/importu/definition.rb +23 -0
  35. data/lib/importu/duplicate_manager.rb +88 -0
  36. data/lib/importu/exceptions.rb +135 -4
  37. data/lib/importu/importer.rb +183 -96
  38. data/lib/importu/record.rb +138 -102
  39. data/lib/importu/sources/csv.rb +122 -0
  40. data/lib/importu/sources/json.rb +106 -0
  41. data/lib/importu/sources/ruby.rb +46 -0
  42. data/lib/importu/sources/xml.rb +133 -0
  43. data/lib/importu/sources.rb +13 -0
  44. data/lib/importu/summary.rb +277 -0
  45. data/lib/importu/version.rb +3 -1
  46. data/lib/importu.rb +45 -9
  47. data/spec/fixtures/books-duplicates/README.md +7 -0
  48. data/spec/fixtures/books-duplicates/infile.csv +7 -0
  49. data/spec/fixtures/books-duplicates/model.json +23 -0
  50. data/spec/fixtures/books-duplicates/summary.json +10 -0
  51. data/spec/fixtures/books-valid/README.md +13 -0
  52. data/spec/fixtures/books-valid/infile.csv +4 -0
  53. data/spec/fixtures/books-valid/infile.json +23 -0
  54. data/spec/fixtures/books-valid/infile.xml +21 -0
  55. data/spec/fixtures/books-valid/model.json +23 -0
  56. data/spec/fixtures/books-valid/record.json +26 -0
  57. data/spec/fixtures/books-valid/summary.json +8 -0
  58. data/spec/fixtures/source-empty-file/infile.csv +0 -0
  59. data/spec/fixtures/source-empty-file/infile.json +0 -0
  60. data/spec/fixtures/source-empty-file/infile.xml +0 -0
  61. data/spec/fixtures/source-empty-records/infile.csv +3 -0
  62. data/spec/fixtures/source-empty-records/infile.json +1 -0
  63. data/spec/fixtures/source-empty-records/infile.xml +6 -0
  64. data/spec/fixtures/source-malformed/infile.csv +1 -0
  65. data/spec/fixtures/source-malformed/infile.json +1 -0
  66. data/spec/fixtures/source-malformed/infile.xml +3 -0
  67. data/spec/fixtures/source-no-records/infile.csv +1 -0
  68. data/spec/fixtures/source-no-records/infile.json +1 -0
  69. data/spec/fixtures/source-no-records/infile.xml +3 -0
  70. data/spec/lib/importu/backends/active_record_spec.rb +150 -0
  71. data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
  72. data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
  73. data/spec/lib/importu/backends_spec.rb +170 -0
  74. data/spec/lib/importu/converters_spec.rb +184 -141
  75. data/spec/lib/importu/definition_spec.rb +248 -0
  76. data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
  77. data/spec/lib/importu/exceptions_spec.rb +69 -16
  78. data/spec/lib/importu/import_context_spec.rb +199 -0
  79. data/spec/lib/importu/importer_spec.rb +95 -0
  80. data/spec/lib/importu/integration_spec.rb +221 -0
  81. data/spec/lib/importu/record_spec.rb +130 -80
  82. data/spec/lib/importu/sources/csv_spec.rb +29 -0
  83. data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
  84. data/spec/lib/importu/sources/json_spec.rb +29 -0
  85. data/spec/lib/importu/sources/ruby_spec.rb +102 -0
  86. data/spec/lib/importu/sources/xml_spec.rb +70 -0
  87. data/spec/lib/importu/summary_spec.rb +186 -0
  88. data/spec/spec_helper.rb +91 -7
  89. data/spec/support/active_record.rb +20 -0
  90. data/spec/support/book_importer.rb +31 -0
  91. data/spec/support/dummy_backend.rb +50 -0
  92. data/spec/support/fixtures_helper.rb +43 -0
  93. data/spec/support/matchers/delegate_matcher.rb +14 -8
  94. metadata +173 -100
  95. data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
  96. data/lib/importu/core_ext/deep_freeze.rb +0 -3
  97. data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
  98. data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
  99. data/lib/importu/core_ext.rb +0 -3
  100. data/lib/importu/dsl.rb +0 -127
  101. data/lib/importu/importer/csv.rb +0 -52
  102. data/lib/importu/importer/json.rb +0 -45
  103. data/lib/importu/importer/xml.rb +0 -55
  104. data/spec/factories/importer.rb +0 -12
  105. data/spec/factories/importer_record.rb +0 -13
  106. data/spec/factories/json_importer.rb +0 -14
  107. data/spec/factories/xml_importer.rb +0 -12
  108. data/spec/lib/importu/dsl_spec.rb +0 -26
  109. data/spec/lib/importu/importer/json_spec.rb +0 -37
  110. 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
- require 'spec_helper'
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
2
3
 
3
- describe Importu::Record do
4
- subject(:record) { build(:importer_record) }
4
+ require "importu/converter_context"
5
+ require "importu/definition"
6
+ require "importu/record"
5
7
 
6
- it "includes Enumerable" do
7
- record.should be_a_kind_of(Enumerable)
8
- end
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
- describe "#importer" do
11
- it "returns the importer used during construction" do
12
- importer = build(:importer)
13
- record = build(:importer_record, :importer => importer)
14
- record.importer.should be importer
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
- it "is delegated from #preprocessor" do
18
- record.should delegate(:preprocessor).to(:importer)
19
- end
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
- it "is delegated from #postprocessor" do
22
- record.should delegate(:postprocessor).to(:importer)
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
- it "is delegated from #definitions" do
26
- record.should delegate(:definitions).to(:importer)
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
- it "is delegated from #converters" do
30
- record.should delegate(:converters).to(:importer)
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 the data used during construction" do
36
- data = { "foo" => "bar" }
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 "#raw_data" do
43
- it "returns the raw_data used during construction" do
44
- raw_data = "this,is\tsome_raw_data\n"
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
- describe "#definitions" do
51
- it "returns the definitions defined in importer on construction" do
52
- importer = build(:importer)
53
- importer.stub(:definitions => { :foo => :bar })
54
- record = build(:importer_record, :importer => importer)
55
- record.definitions.should be importer.definitions
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 "#record_hash" do
60
- it "tries to generate a record hash on first access" do
61
- expected = { :foo => 1, :bar => 2 }
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 "should not try to regenerate record hash no subsequent access" do
67
- expected = { :foo => 1, :bar => 2 }
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
- it "is aliased from #to_hash" do
74
- record.should_receive(:record_hash).and_return(:called)
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
- it "is delegated from #keys" do
79
- record.should delegate(:keys).to(:record_hash)
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
- it "is delegated from #values" do
83
- record.should delegate(:values).to(:record_hash)
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
- it "is delegated from #each" do
87
- record.should delegate(:each).to(:record_hash)
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
- it "is delegated from #[]" do
91
- record.should delegate(:[]).to(:record_hash)
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
- it "is delegated from #key?" do
95
- record.should delegate(:key?).to(:record_hash)
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
- describe "#convert" do
99
- context "with a :default option" do
100
- it "returns data value if data value not nil" do
101
- record.converters[:clean] = proc { "value1" }
102
- record.convert(:field1, :clean, :default => "foobar").should eq "value1"
103
- end
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
- it "returns default value if data value is nil" do
106
- record.converters[:clean] = proc { nil }
107
- record.convert(:field1, :clean, :default => "foobar").should eq "foobar"
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
- it "returns default value if data field is missing and not required" do
111
- record.converters[:clean] = proc { raise Importu::MissingField, "field1" }
112
- record.convert(:field1, :clean, :default => "foobar").should eq "foobar"
113
- end
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
- it "raises an exception if data field is missing and is required" do
116
- record.converters[:clean] = proc { raise Importu::MissingField, "field1" }
117
- expect { record.convert(:field1, :clean, :default => "foobar", :required => true) }.to raise_error(Importu::MissingField)
118
- end
119
- end
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