speaky_csv 0.0.3 → 0.0.5
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 +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
|