speaky_csv 0.0.3 → 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Guardfile +1 -1
- data/README.md +258 -67
- data/lib/speaky_csv.rb +3 -0
- data/lib/speaky_csv/active_record_import.rb +8 -6
- data/lib/speaky_csv/attr_import.rb +29 -10
- data/lib/speaky_csv/base.rb +9 -73
- data/lib/speaky_csv/config.rb +30 -0
- data/lib/speaky_csv/config_builder.rb +74 -0
- data/lib/speaky_csv/export.rb +14 -1
- data/lib/speaky_csv/version.rb +1 -1
- data/spec/active_record_import_spec.rb +78 -4
- data/spec/attr_import_spec.rb +160 -2
- data/spec/base_spec.rb +33 -10
- data/spec/export_spec.rb +144 -0
- data/spec/readme_spec.rb +95 -0
- data/spec/support/active_record.rb +1 -0
- metadata +40 -36
data/lib/speaky_csv/base.rb
CHANGED
@@ -1,81 +1,17 @@
|
|
1
1
|
module SpeakyCsv
|
2
|
-
# An instance of this class is yielded to the block passed to
|
3
|
-
# define_csv_fields. Used to configure speaky csv.
|
4
|
-
class Builder
|
5
|
-
attr_reader \
|
6
|
-
:export_only_fields,
|
7
|
-
:fields,
|
8
|
-
:has_manys,
|
9
|
-
:has_ones,
|
10
|
-
:primary_key
|
11
|
-
|
12
|
-
def initialize
|
13
|
-
@export_only_fields = []
|
14
|
-
@fields = []
|
15
|
-
@has_manys = {}
|
16
|
-
@has_ones = {}
|
17
|
-
@primary_key = :id
|
18
|
-
end
|
19
|
-
|
20
|
-
# Add one or many fields to the csv format.
|
21
|
-
#
|
22
|
-
# If options are passed, they apply to all given fields.
|
23
|
-
def field(*fields, export_only: false)
|
24
|
-
@fields += fields.map(&:to_sym)
|
25
|
-
@fields.uniq!
|
26
|
-
|
27
|
-
if export_only
|
28
|
-
@export_only_fields += fields.map(&:to_sym)
|
29
|
-
@export_only_fields.uniq!
|
30
|
-
end
|
31
|
-
|
32
|
-
nil
|
33
|
-
end
|
34
|
-
|
35
|
-
# Define a custom primary key. By default an `id` column as used.
|
36
|
-
#
|
37
|
-
# Accepts the same options as #field
|
38
|
-
def primary_key=(name, options = {})
|
39
|
-
field name, options
|
40
|
-
@primary_key = name.to_sym
|
41
|
-
end
|
42
|
-
|
43
|
-
def has_one(name)
|
44
|
-
@has_ones[name.to_sym] ||= self.class.new
|
45
|
-
yield @has_ones[name.to_sym]
|
46
|
-
|
47
|
-
nil
|
48
|
-
end
|
49
|
-
|
50
|
-
def has_many(name)
|
51
|
-
@has_manys[name.to_sym] ||= self.class.new
|
52
|
-
yield @has_manys[name.to_sym]
|
53
|
-
|
54
|
-
nil
|
55
|
-
end
|
56
|
-
|
57
|
-
def dup
|
58
|
-
other = super
|
59
|
-
other.instance_variable_set '@has_manys', @has_manys.deep_dup
|
60
|
-
other.instance_variable_set '@has_ones', @has_ones.deep_dup
|
61
|
-
|
62
|
-
other
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
2
|
# Inherit from this class when using SpeakyCsv
|
67
3
|
class Base
|
68
|
-
class_attribute :
|
69
|
-
self.
|
4
|
+
class_attribute :speaky_csv_config
|
5
|
+
self.speaky_csv_config = Config.new
|
70
6
|
|
71
7
|
def self.define_csv_fields
|
72
|
-
self.
|
73
|
-
yield
|
8
|
+
self.speaky_csv_config = speaky_csv_config.deep_dup
|
9
|
+
yield ConfigBuilder.new(config: speaky_csv_config)
|
74
10
|
end
|
75
11
|
|
76
12
|
# Return a new exporter instance
|
77
13
|
def self.exporter(records_enumerator)
|
78
|
-
Export.new
|
14
|
+
Export.new speaky_csv_config,
|
79
15
|
records_enumerator
|
80
16
|
end
|
81
17
|
|
@@ -98,8 +34,8 @@ module SpeakyCsv
|
|
98
34
|
# end
|
99
35
|
# end
|
100
36
|
#
|
101
|
-
def attr_importer(input_io)
|
102
|
-
AttrImport.new
|
37
|
+
def self.attr_importer(input_io)
|
38
|
+
AttrImport.new speaky_csv_config,
|
103
39
|
input_io
|
104
40
|
end
|
105
41
|
|
@@ -125,9 +61,9 @@ module SpeakyCsv
|
|
125
61
|
# Optionally an Enumerable instance can be passed instead of an IO
|
126
62
|
# instance. The enumerable should return attr hashes. This may be helpful
|
127
63
|
# for transforming or chaining Enumerables.
|
128
|
-
def active_record_importer(input_io_or_enumerable, klass)
|
64
|
+
def self.active_record_importer(input_io_or_enumerable, klass)
|
129
65
|
ActiveRecordImport.new \
|
130
|
-
|
66
|
+
speaky_csv_config,
|
131
67
|
input_io_or_enumerable,
|
132
68
|
klass
|
133
69
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SpeakyCsv
|
2
|
+
# An instance of this class is yielded to the block passed to
|
3
|
+
# define_csv_fields. Used to configure speaky csv.
|
4
|
+
class Config
|
5
|
+
attr_accessor \
|
6
|
+
:export_only_fields,
|
7
|
+
:fields,
|
8
|
+
:has_manys,
|
9
|
+
:has_ones,
|
10
|
+
:primary_key,
|
11
|
+
:root
|
12
|
+
|
13
|
+
def initialize(root: true)
|
14
|
+
@root = root
|
15
|
+
@export_only_fields = []
|
16
|
+
@fields = []
|
17
|
+
@has_manys = {}
|
18
|
+
@has_ones = {}
|
19
|
+
@primary_key = :id
|
20
|
+
end
|
21
|
+
|
22
|
+
def dup
|
23
|
+
other = super
|
24
|
+
other.instance_variable_set '@has_manys', @has_manys.deep_dup
|
25
|
+
other.instance_variable_set '@has_ones', @has_ones.deep_dup
|
26
|
+
|
27
|
+
other
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module SpeakyCsv
|
2
|
+
# An instance of this class is yielded to the block passed to
|
3
|
+
# define_csv_fields. Used to configure speaky csv.
|
4
|
+
class ConfigBuilder
|
5
|
+
attr_reader :config
|
6
|
+
|
7
|
+
def initialize(config: Config.new, root: true)
|
8
|
+
@config = config
|
9
|
+
@config.root = root
|
10
|
+
end
|
11
|
+
|
12
|
+
# Add one or many fields to the csv format.
|
13
|
+
#
|
14
|
+
# If options are passed, they apply to all given fields.
|
15
|
+
def field(*fields, export_only: false)
|
16
|
+
@config.fields += fields.map(&:to_sym)
|
17
|
+
@config.fields.uniq!
|
18
|
+
|
19
|
+
if export_only
|
20
|
+
@config.export_only_fields += fields.map(&:to_sym)
|
21
|
+
@config.export_only_fields.uniq!
|
22
|
+
end
|
23
|
+
|
24
|
+
nil
|
25
|
+
end
|
26
|
+
|
27
|
+
# Define a custom primary key. By default an `id` column as used.
|
28
|
+
#
|
29
|
+
# Accepts the same options as #field
|
30
|
+
def primary_key=(name, options = {})
|
31
|
+
field name, options
|
32
|
+
@config.primary_key = name.to_sym
|
33
|
+
end
|
34
|
+
|
35
|
+
# Define a one to one association. This is also aliased as `belongs_to`. Expects a name and a block to
|
36
|
+
# define the fields on associated record.
|
37
|
+
#
|
38
|
+
# For example:
|
39
|
+
#
|
40
|
+
# define_csv_fields do |c|
|
41
|
+
# has_many 'publisher' do |p|
|
42
|
+
# p.field :id, :name, :_destroy
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
def has_one(name)
|
47
|
+
@config.root or raise NotImplementedError, "nested associations are not supported"
|
48
|
+
@config.has_ones[name.to_sym] ||= Config.new
|
49
|
+
yield self.class.new config: @config.has_ones[name.to_sym], root: false
|
50
|
+
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
alias :belongs_to :has_one
|
54
|
+
|
55
|
+
# Define a one to many association. Expect a name and a block to
|
56
|
+
# define the fields on associated records.
|
57
|
+
#
|
58
|
+
# For example:
|
59
|
+
#
|
60
|
+
# define_csv_fields do |c|
|
61
|
+
# has_many 'reviews' do |r|
|
62
|
+
# r.field :id, :name, :_destroy
|
63
|
+
# end
|
64
|
+
# end
|
65
|
+
#
|
66
|
+
def has_many(name)
|
67
|
+
@config.root or raise NotImplementedError, "nested associations are not supported"
|
68
|
+
@config.has_manys[name.to_sym] ||= Config.new
|
69
|
+
yield self.class.new config: @config.has_manys[name.to_sym], root: false
|
70
|
+
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/speaky_csv/export.rb
CHANGED
@@ -32,6 +32,7 @@ module SpeakyCsv
|
|
32
32
|
|
33
33
|
def valid_field?(record, field, prefix: nil)
|
34
34
|
return true if record.respond_to? field
|
35
|
+
return false if field == :_destroy
|
35
36
|
|
36
37
|
error_name = prefix ? "#{prefix}_#{field}" : field
|
37
38
|
logger.error "#{error_name} is not a method for class #{record.class}"
|
@@ -43,8 +44,13 @@ module SpeakyCsv
|
|
43
44
|
return @enumerator if defined? @enumerator
|
44
45
|
|
45
46
|
@enumerator = Enumerator.new do |yielder|
|
47
|
+
columns = @config.fields
|
48
|
+
columns += @config.has_ones.flat_map do |name, config|
|
49
|
+
config.fields.map {|f| "#{name}_#{f}" }
|
50
|
+
end
|
51
|
+
|
46
52
|
# header row
|
47
|
-
yielder << CSV::Row.new(
|
53
|
+
yielder << CSV::Row.new(columns, columns, true).to_csv
|
48
54
|
|
49
55
|
@records_enumerator.each do |record|
|
50
56
|
values = @config.fields
|
@@ -62,6 +68,13 @@ module SpeakyCsv
|
|
62
68
|
end
|
63
69
|
end
|
64
70
|
|
71
|
+
@config.has_ones.select { |a| valid_field? record, a }.each do |name, config|
|
72
|
+
has_one_record = record.send name
|
73
|
+
config.fields.select { |f| valid_field? has_one_record, f, prefix: name }.each do |field|
|
74
|
+
row << has_one_record.send(field)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
65
78
|
yielder << row.to_csv
|
66
79
|
end
|
67
80
|
end
|
data/lib/speaky_csv/version.rb
CHANGED
@@ -5,7 +5,7 @@ describe SpeakyCsv::ActiveRecordImport, :db do
|
|
5
5
|
let(:presenter_klass) { Class.new SpeakyCsv::Base }
|
6
6
|
|
7
7
|
let(:io) { StringIO.new }
|
8
|
-
subject { presenter_klass.
|
8
|
+
subject { presenter_klass.active_record_importer io, Book }
|
9
9
|
|
10
10
|
def record
|
11
11
|
unless defined? @record
|
@@ -119,7 +119,7 @@ Big Fiction,Sneed
|
|
119
119
|
before do
|
120
120
|
presenter_klass.class_eval do
|
121
121
|
define_csv_fields do |d|
|
122
|
-
d.field :id
|
122
|
+
d.field :id, :name, :author
|
123
123
|
end
|
124
124
|
end
|
125
125
|
end
|
@@ -170,7 +170,7 @@ id,name,whats_this
|
|
170
170
|
end
|
171
171
|
end
|
172
172
|
|
173
|
-
context 'with has_many
|
173
|
+
context 'with has_many association' do
|
174
174
|
before do
|
175
175
|
presenter_klass.class_eval do
|
176
176
|
define_csv_fields do |d|
|
@@ -255,6 +255,80 @@ id
|
|
255
255
|
end
|
256
256
|
end
|
257
257
|
|
258
|
+
context 'with has_one association' do
|
259
|
+
before do
|
260
|
+
presenter_klass.class_eval do
|
261
|
+
define_csv_fields do |d|
|
262
|
+
d.field :id
|
263
|
+
d.has_one 'publisher' do |r|
|
264
|
+
r.field :id, :name, :_destroy
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
let!(:book) { Book.create! id: 1 }
|
271
|
+
|
272
|
+
context 'and csv has new associated record' do
|
273
|
+
let(:io) do
|
274
|
+
StringIO.new <<-CSV
|
275
|
+
id,publisher_id,publisher_name
|
276
|
+
1,,Dan Blam
|
277
|
+
CSV
|
278
|
+
end
|
279
|
+
|
280
|
+
it 'builds new record' do
|
281
|
+
expect(record.publisher.attributes).to include('id' => nil, 'name' => 'Dan Blam')
|
282
|
+
expect(record.publisher).to be_new_record
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
#context 'and csv has unchanged associated record' do
|
287
|
+
#let(:io) do
|
288
|
+
#StringIO.new <<-CSV
|
289
|
+
#id
|
290
|
+
#1,review_0_id,1,review_0_tomatoes,99,review_0_publication,Post
|
291
|
+
#CSV
|
292
|
+
#end
|
293
|
+
|
294
|
+
#let!(:review) { Review.create! id: 1, book: book, tomatoes: 99, publication: 'Post' }
|
295
|
+
|
296
|
+
#it 'returns clean record' do
|
297
|
+
#expect(actual_review).to_not be_changed
|
298
|
+
#end
|
299
|
+
#end
|
300
|
+
|
301
|
+
#context 'and csv changes associated record' do
|
302
|
+
#let(:io) do
|
303
|
+
#StringIO.new <<-CSV
|
304
|
+
#id
|
305
|
+
#1,review_0_id,1,review_0_tomatoes,80,review_0_publication,Post
|
306
|
+
#CSV
|
307
|
+
#end
|
308
|
+
|
309
|
+
#let!(:review) { Review.create! id: 1, book: book, tomatoes: 99, publication: 'Post' }
|
310
|
+
|
311
|
+
#it 'returns clean record' do
|
312
|
+
#expect(actual_review.tomatoes).to eq 80
|
313
|
+
#expect(actual_review).to be_changed
|
314
|
+
#end
|
315
|
+
#end
|
316
|
+
#context 'and csv destroys associated record' do
|
317
|
+
#let(:io) do
|
318
|
+
#StringIO.new <<-CSV
|
319
|
+
#id
|
320
|
+
#1,review_0_id,1,review_0__destroy,true
|
321
|
+
#CSV
|
322
|
+
#end
|
323
|
+
|
324
|
+
#let!(:review) { Review.create! id: 1, book: book, tomatoes: 99, publication: 'Post' }
|
325
|
+
|
326
|
+
#it 'marks record for destruction' do
|
327
|
+
#expect(actual_review).to be_marked_for_destruction
|
328
|
+
#end
|
329
|
+
#end
|
330
|
+
end
|
331
|
+
|
258
332
|
it 'should fail when variable columns not pair up correctly'
|
259
333
|
|
260
334
|
describe 'batch behavior' do
|
@@ -368,7 +442,7 @@ hihihi
|
|
368
442
|
end
|
369
443
|
|
370
444
|
context 'with enumerator' do
|
371
|
-
subject { presenter_klass.
|
445
|
+
subject { presenter_klass.active_record_importer enumerator, Book }
|
372
446
|
|
373
447
|
before do
|
374
448
|
presenter_klass.class_eval do
|
data/spec/attr_import_spec.rb
CHANGED
@@ -4,7 +4,7 @@ describe SpeakyCsv::AttrImport do
|
|
4
4
|
let(:presenter_klass) { Class.new SpeakyCsv::Base }
|
5
5
|
|
6
6
|
let(:io) { StringIO.new }
|
7
|
-
subject { presenter_klass.
|
7
|
+
subject { presenter_klass.attr_importer io }
|
8
8
|
|
9
9
|
context 'with fields' do
|
10
10
|
before do
|
@@ -29,6 +29,40 @@ True story,Honest Abe
|
|
29
29
|
{ 'name' => 'True story', 'author' => 'Honest Abe' }
|
30
30
|
])
|
31
31
|
end
|
32
|
+
|
33
|
+
context "when csv cell is empty" do
|
34
|
+
let(:io) do
|
35
|
+
StringIO.new <<-CSV
|
36
|
+
name,author
|
37
|
+
,Sneed
|
38
|
+
True story,
|
39
|
+
CSV
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'should return nil values' do
|
43
|
+
expect(subject.to_a).to eq([
|
44
|
+
{ 'name' => nil, 'author' => 'Sneed' },
|
45
|
+
{ 'name' => 'True story', 'author' => nil }
|
46
|
+
])
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context "when a column isn't present" do
|
51
|
+
let(:io) do
|
52
|
+
StringIO.new <<-CSV
|
53
|
+
name
|
54
|
+
Big Fiction
|
55
|
+
True story
|
56
|
+
CSV
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'should not include that field in output' do
|
60
|
+
expect(subject.to_a).to eq([
|
61
|
+
{ 'name' => 'Big Fiction' },
|
62
|
+
{ 'name' => 'True story' }
|
63
|
+
])
|
64
|
+
end
|
65
|
+
end
|
32
66
|
end
|
33
67
|
|
34
68
|
context 'with export_only field' do
|
@@ -58,7 +92,7 @@ id,name
|
|
58
92
|
end
|
59
93
|
end
|
60
94
|
|
61
|
-
context 'with unknown
|
95
|
+
context 'with unknown columns' do
|
62
96
|
before do
|
63
97
|
presenter_klass.class_eval do
|
64
98
|
define_csv_fields do |d|
|
@@ -127,6 +161,130 @@ True story,Honest Abe,review_0_tomatoes,50,review_0_publication,Daily
|
|
127
161
|
}
|
128
162
|
])
|
129
163
|
end
|
164
|
+
|
165
|
+
context 'when csv cell is empty' do
|
166
|
+
let(:io) do
|
167
|
+
StringIO.new <<-CSV
|
168
|
+
name,author,,,,,,,,
|
169
|
+
Big Fiction,Sneed,review_0_tomatoes,,review_0_publication,,review_1_tomatoes,,review_1_publication
|
170
|
+
True story,Honest Abe,review_0_tomatoes,,review_0_publication,,
|
171
|
+
CSV
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'should return nil values' do
|
175
|
+
expect(subject.to_a).to eq([
|
176
|
+
{
|
177
|
+
'name' => 'Big Fiction',
|
178
|
+
'author' => 'Sneed',
|
179
|
+
'reviews' => [
|
180
|
+
{ 'tomatoes' => nil, 'publication' => nil },
|
181
|
+
{ 'tomatoes' => nil, 'publication' => nil }
|
182
|
+
]
|
183
|
+
},
|
184
|
+
{
|
185
|
+
'name' => 'True story',
|
186
|
+
'author' => 'Honest Abe',
|
187
|
+
'reviews' => [
|
188
|
+
{ 'tomatoes' => nil, 'publication' => nil }
|
189
|
+
]
|
190
|
+
}
|
191
|
+
])
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
context 'with has_one fields' do
|
197
|
+
before do
|
198
|
+
presenter_klass.class_eval do
|
199
|
+
define_csv_fields do |d|
|
200
|
+
d.field 'name', 'author'
|
201
|
+
d.has_one 'publisher' do |r|
|
202
|
+
r.field :id, :name
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
let(:io) do
|
209
|
+
StringIO.new <<-CSV
|
210
|
+
name,author,publisher_id,publisher_name,
|
211
|
+
Big Fiction,Sneed,3,Dan Blam
|
212
|
+
True story,Honest Abe,50,Burt Bacharach
|
213
|
+
CSV
|
214
|
+
end
|
215
|
+
|
216
|
+
it 'should return attrs' do
|
217
|
+
expect(subject.to_a).to eq([
|
218
|
+
{
|
219
|
+
'name' => 'Big Fiction',
|
220
|
+
'author' => 'Sneed',
|
221
|
+
'publisher' => {
|
222
|
+
'id' => '3', 'name' => 'Dan Blam'
|
223
|
+
},
|
224
|
+
},
|
225
|
+
{
|
226
|
+
'name' => 'True story',
|
227
|
+
'author' => 'Honest Abe',
|
228
|
+
'publisher' => {
|
229
|
+
'id' => '50', 'name' => 'Burt Bacharach'
|
230
|
+
}
|
231
|
+
}
|
232
|
+
])
|
233
|
+
end
|
234
|
+
|
235
|
+
context 'when csv cell is empty' do
|
236
|
+
let(:io) do
|
237
|
+
StringIO.new <<-CSV
|
238
|
+
name,author,publisher_id,publisher_name,
|
239
|
+
Big Fiction,Sneed,,Dan Blam
|
240
|
+
True story,Honest Abe,50,
|
241
|
+
CSV
|
242
|
+
end
|
243
|
+
|
244
|
+
it 'should return nil values' do
|
245
|
+
expect(subject.to_a).to eq([
|
246
|
+
{
|
247
|
+
'name' => 'Big Fiction',
|
248
|
+
'author' => 'Sneed',
|
249
|
+
'publisher' => {
|
250
|
+
'id' => nil, 'name' => 'Dan Blam'
|
251
|
+
},
|
252
|
+
},
|
253
|
+
{
|
254
|
+
'name' => 'True story',
|
255
|
+
'author' => 'Honest Abe',
|
256
|
+
'publisher' => {
|
257
|
+
'id' => '50', 'name' => nil
|
258
|
+
}
|
259
|
+
}
|
260
|
+
])
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
context "when a column isn't present" do
|
265
|
+
let(:io) do
|
266
|
+
StringIO.new <<-CSV
|
267
|
+
name,author,publisher_id
|
268
|
+
Big Fiction,Sneed,3
|
269
|
+
True story,Honest Abe,50
|
270
|
+
CSV
|
271
|
+
end
|
272
|
+
|
273
|
+
it 'should not include that field in output' do
|
274
|
+
expect(subject.to_a).to eq([
|
275
|
+
{
|
276
|
+
'name' => 'Big Fiction',
|
277
|
+
'author' => 'Sneed',
|
278
|
+
'publisher' => { 'id' => '3' },
|
279
|
+
},
|
280
|
+
{
|
281
|
+
'name' => 'True story',
|
282
|
+
'author' => 'Honest Abe',
|
283
|
+
'publisher' => { 'id' => '50' }
|
284
|
+
}
|
285
|
+
])
|
286
|
+
end
|
287
|
+
end
|
130
288
|
end
|
131
289
|
|
132
290
|
context 'with export_only has_many field' do
|