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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 7fad11aca11ab34b9b05459bbc6282ec467d060a
4
- data.tar.gz: 0beefbd6193709798f83e1c083022514840d65f8
3
+ metadata.gz: 1d5c7405ab99d006069011e4008088bbd2ff062b
4
+ data.tar.gz: 4b6e533cd7619fb4a8bdb9c01bddbf28d4b59fc3
5
5
  SHA512:
6
- metadata.gz: 611f717ba2b8539ec43ca525699d92baef358b5950c11d5c6035adba41b2bcd94d3297a42b7a5fb22b92cfc24993e53921d0d0d5c02658416878ca0bb9b88d74
7
- data.tar.gz: 3f2dfd7c80f985470eff75bc58745a88b54a43b27f4d214410f870468abc7c908a2c11fc7da88de37ed857452b231462e8b1cf8685de4a6d9f3e28875d269255
6
+ metadata.gz: 4f1a2152d8b1ecb4cb05cb0f682ebba9cd65f2b8fdeb9e4e733899abfc55e4be9d87e5473aebd420a7e6276a4136d21743a93d149b7ef9376f2e8055714f033b
7
+ data.tar.gz: 07e0c6cc87f6f47454e8a28eb27d75e9176cf7ef7fd9e943f3428fb474c4defff85bfa88580f081c1b95ccefba171d483e80971bfc997075cb038f4e3c632ab8
data/Guardfile CHANGED
@@ -1,5 +1,5 @@
1
1
  guard :rspec, cmd: 'rspec' do
2
2
  watch(%r{^spec/.+_spec\.rb$})
3
- watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
3
+ watch(%r{^lib/(.+)\.rb$}) { 'spec' }
4
4
  watch('spec/spec_helper.rb') { 'spec' }
5
5
  end
data/README.md CHANGED
@@ -2,104 +2,293 @@
2
2
 
3
3
  CSV imports and exports for ActiveRecord.
4
4
 
5
- ## For example
5
+ Speaky CSV features:
6
6
 
7
- Lets say there exists a User class:
7
+ * An easy to use API,
8
+ * Speedy stream processing using enumerators.
8
9
 
9
- # in app/models/user.rb
10
- class User < ActiveRecord::Base
11
- ...
12
- end
10
+ ## Installation
13
11
 
14
- Speaky can be used to import and export user records. The definition of
15
- the csv format could look like this:
12
+ Add this line to your application's Gemfile:
16
13
 
17
- # in app/csv/user_csv.rb
18
- class UserCsv < SpeakyCsv::Base
19
- define_csv_fields do |config|
20
- config.field :id, :email, :roles
21
- end
22
- end
14
+ gem 'speaky_csv'
23
15
 
24
- Now lets import some user records. An import csv will always have an
25
- initial header row, with each following row representing a user record.
26
- lets import the following csv file (whitespace for clarity):
16
+ And then execute:
27
17
 
28
- # my_import.csv
29
- id, email, roles
30
- 22, admin@example.test, admin
31
- , newbie@user.test, user
18
+ $ bundle
32
19
 
33
- This file can be imported like this:
20
+ Or install it yourself as:
34
21
 
35
- File.open "my_import.csv", "r" do |io|
36
- importer = UserCsv.new.active_record_importer io, User
37
- importer.each { |user| user.save }
38
- end
22
+ $ gem install speaky_csv
39
23
 
40
- ## Custom CSV formats
24
+ ## Usage
41
25
 
42
- Speaky
26
+ Let's say you build software for your local public library and there
27
+ exists a Book model:
43
28
 
29
+ ```ruby
30
+ # in app/models/book.rb
31
+ class Book < ActiveRecord::Base
32
+ # ...
33
+ end
34
+ ```
44
35
 
45
- Speaky allows customization of csv files to a degree, but some
46
- conventions need to be followed.
36
+ Speaky can be used to import and export book records using csv files.
37
+ The definition of the csv format could look like this:
47
38
 
48
- At a high level, the csv
49
- ends up looking similar to the way active record data gets serialized
50
- into form parameters which will be familiar to many rails developers.
51
- The advantage of this approach is that associated records be imported
52
- and exported.
39
+ ```ruby
40
+ # in app/csvs/book_csv.rb
41
+ class BookCsv < SpeakyCsv::Base
42
+ define_csv_fields do |config|
43
+ config.field :id, :author
44
+ end
45
+ end
46
+ ```
53
47
 
54
- ## Installation
48
+ This defines a CSV format that looks like this:
55
49
 
56
- Add this line to your application's Gemfile:
50
+ id,author
51
+ 3,Stevenson
52
+ 19,Melville
53
+ 1,Macaulay
57
54
 
58
- gem 'speaky_csv'
55
+ ## Exporting
59
56
 
60
- And then execute:
57
+ Creating a csv file from records in a database can be done with the
58
+ exporter:
61
59
 
62
- $ bundle
60
+ ```ruby
61
+ # in app/csvs/book_csv.rb
62
+ class BookCsv < SpeakyCsv::Base
63
+ define_csv_fields do |config|
64
+ config.field :id, :author, :_destroy
65
+ end
66
+ end
63
67
 
64
- Or install it yourself as:
68
+ books = [
69
+ Book.create!(author: 'Stevenson'),
70
+ Book.create!(author: 'Melville'),
71
+ Book.create!(author: 'Macaulay'),
72
+ ]
65
73
 
66
- $ gem install speaky_csv
74
+ exporter = BookCsv.exporter books
67
75
 
68
- ## Usage
76
+ io = StringIO.new
77
+ exporter.each { |row| io.write row }
78
+ ```
79
+
80
+ `io` will have the following contents:
81
+
82
+ id,author,_destroy
83
+ 2,Stevenson,false
84
+ 3,Melville,false
85
+ 4,Macaulay,false
86
+
87
+ ##### With Associations
88
+
89
+ Associations can also be exported.
90
+
91
+ ```ruby
92
+ # in app/csvs/book_csv.rb
93
+ class BookCsv < SpeakyCsv::Base
94
+ define_csv_fields do |config|
95
+ config.field :id, :author
96
+
97
+ config.belongs_to :publisher do |p|
98
+ p.field :id, :name
99
+ end
100
+
101
+ config.has_many :reviews do |r|
102
+ r.field :id, :tomatoes, :publication
103
+ end
104
+ end
105
+ end
106
+ ```
107
+
108
+ This defines a CSV format that looks like this:
109
+
110
+ id,author,publisher_id,publisher_name
111
+ 3,Stevenson,22,Blam Ltd
112
+ 19,Melville,,
113
+ 1,Macaulay,83,NY Tiempo,reviews_0_id,8,reviews_0_tomatoes,50,review_0_publication,Daily
114
+
115
+ Since a book only ever has one publisher, these can get dedicated
116
+ columns with headers (the `publisher_id` and `publisher_name` columns).
117
+ Reviews are more tricky because there can be several that need to be
118
+ serialized to a single csv row. Speaky CSV uses a convention similar to
119
+ how rails and rack deal with query parameters for things like multi
120
+ select form inputs.
121
+
122
+ ## Importing
123
+
124
+ Now lets import some books. Speaky will expect an import to have
125
+ an initial header row, and each subsequent row to represent a user record.
126
+ Columns can be in any order.
127
+
128
+ Let's create a book by importing a csv.
129
+
130
+ ```ruby
131
+ csv_io = StringIO.new <<-CSV
132
+ id,author
133
+ ,Sneed
134
+ CSV
135
+ ```
136
+
137
+ Notice the empty id column. This tells Speaky that the operation is
138
+ a create. The file can be imported like this:
139
+
140
+ ```ruby
141
+ importer = BookCsv.active_record_importer csv_io, Book
142
+ importer.each { |book| book.save }
143
+ Book.last.author == 'Sneed' # => true
144
+ ```
145
+
146
+ This importer is an `active record importer`, which means that `#each`
147
+ will return active record instances. There is also an `attribute importer`
148
+ that will return hashes of attribute name => values. See the rdoc for
149
+ more info on that.
150
+
151
+ ##### Update
152
+
153
+ Let's change the author value:
154
+
155
+ ```ruby
156
+ csv_io = StringIO.new <<-CSV
157
+ id,author
158
+ 1,Simon Sneed
159
+ CSV
160
+ ```
161
+
162
+ Now there is an id value in the csv. Having an id value will cause
163
+ Speaky to find the record with the given id and update it.
164
+
165
+ ```ruby
166
+ importer = BookCsv.active_record_importer csv_io, Book
167
+ importer.each { |book| book.save }
168
+ expect(Book.last.author).to eq 'Simon Sneed'
169
+ ```
170
+
171
+ If a record with the given id isn't found, the importer will return a
172
+ nil for that row instead of an active record and add a message a log
173
+ file:
174
+
175
+ ```ruby
176
+ csv_io = StringIO.new <<-CSV
177
+ id,author
178
+ 234,I dont exist
179
+ CSV
180
+
181
+ importer = BookCsv.active_record_importer csv_io, Book
182
+ importer.to_a # => [nil]
183
+ importer.log # => '...[row 1] record not found with primary key: "234"....'
184
+ ```
185
+
186
+ For more info on the log file see below.
187
+
188
+ ##### Destroy
189
+
190
+ To destroy the record, we'll need to change the csv format to add a
191
+ `_destroy` field. If this column contains a true value like: 'true' or
192
+ '1', the record will be marked for destruction.
193
+
194
+ Marking an active record for destruction is documented here:
195
+ http://api.rubyonrails.org/v4.2.0/classes/ActiveRecord/AutosaveAssociation.html#method-i-marked_for_destruction-3F
196
+
197
+ ```ruby
198
+ # in app/csvs/book_csv.rb
199
+ class BookCsv < SpeakyCsv::Base
200
+ define_csv_fields do |config|
201
+ config.field :id, :author, :_destroy
202
+ end
203
+ end
204
+
205
+ csv_io = StringIO.new <<-CSV
206
+ id,_destroy
207
+ 1,true
208
+ CSV
209
+
210
+ importer = BookCsv.active_record_importer csv_io, Book
211
+ book = importer.to_a.first
212
+ if book.marked_for_destruction?
213
+ book.destroy
214
+ end
215
+ ```
216
+
217
+ ##### With Associations
218
+
219
+ Speaky uses the active record `accepts_nested_attributes_for` feature to
220
+ deal with importing association data.
221
+
222
+ For example, if a belongs\_to association is configured:
223
+
224
+ ```ruby
225
+ # in app/csvs/book_csv.rb
226
+ class BookCsv < SpeakyCsv::Base
227
+ define_csv_fields do |config|
228
+ config.field :id, :author
229
+
230
+ config.belongs_to :publisher do |p|
231
+ p.field :id, :name
232
+ end
233
+ end
234
+ end
235
+ ```
236
+
237
+ And the csv file being imported is this:
238
+
239
+ id,author,publisher_id,publisher_name
240
+ 3,Stevenson,22,Blam Ltd
69
241
 
70
- Subclass SpeakyCsv::Base and define a csv format for an active
71
- record class. For example:
242
+ Then speaky will find a Booking record with id `3` and call:
72
243
 
73
- # in app/csv/user_csv.rb
74
- class UserCsv < SpeakyCsv::Base
75
- define_csv_fields do |config|
76
- config.field :id, :first_name, :last_name, :email
244
+ ```ruby
245
+ booking.publisher_attributes = {id: '22', name: 'Blam Ltd'}
246
+ ```
77
247
 
78
- config.has_many :roles do |r|
79
- r.field :role_name
80
- end
81
- end
248
+ For a has\_many association, if the configuration looked like this:
249
+
250
+ ```ruby
251
+ # in app/csvs/book_csv.rb
252
+ class BookCsv < SpeakyCsv::Base
253
+ define_csv_fields do |config|
254
+ config.field :id, :author
255
+
256
+ config.has_many :reviews do |r|
257
+ r.field :id, :tomatoes, :publication
82
258
  end
259
+ end
260
+ end
261
+ ```
262
+
263
+ And an import csv looked like this:
264
+
265
+ id,author,publisher_id,publisher_name
266
+ 1,Macaulay,83,NY Tiempo,reviews_0_id,8,reviews_0_tomatoes,50,review_0_publication,Daily
83
267
 
84
- See the rdoc for more details on how to configure the format.
268
+ The speaky will find a Booking record with id `1` and call:
85
269
 
86
- Once the format is defined records can be exported like this:
270
+ ```ruby
271
+ booking.reviews_attributes = [{id: '8', tomatoes: '50', publication: 'Daily'}]
272
+ ```
87
273
 
88
- $ exporter = UserCsv.new.exporter(User.all)
89
- $ File.open('users.csv', 'w') { |io| exporter.each { |row| io.write row } }
274
+ ## Log Messages
90
275
 
91
- TODO:
276
+ Importers and exporters use a `Logger` instance to write messages during
277
+ processing. The default logger writes to a string that can be retrieved
278
+ by the `#log` method. A custom Logger can be set by the `#logger=`
279
+ method.
92
280
 
93
- * describe importing to attribute list
94
- * describe importing to to active records
95
- * describe how to transform with an enumerator
281
+ See `Logger` in the ruby stdlib for more details.
96
282
 
97
- ## Recommendations
283
+ ## Best Practices
98
284
 
99
- * Add `id` and `_destroy` fields for active record models
285
+ * Configure speaky with `id` and `_destroy` fields for active record models
100
286
  * For associations, use `nested_attributes_for` and add `id` and
101
287
  `_destroy` fields
102
- * Use optimistic locking and add `lock_version` to csv
288
+ * Use optimistic locking and configure a `lock_version` field
289
+ * Consider building a draft or preview feature for importing which
290
+ doesn't persist the record by calling `save` but instead reports what
291
+ the changes would be using `ActiveModel::Dirty`
103
292
 
104
293
  ## TODO
105
294
 
@@ -108,11 +297,13 @@ TODO:
108
297
  * [x] export validations
109
298
  * [x] attr import validations
110
299
  * [x] active record import validations
111
- * [ ] `has_one` associations
300
+ * [x] `has_one` associations
112
301
  * [ ] required fields (make `lock_version` required for example)
113
302
  * [ ] transformations for values via accessors on class
114
- * [ ] public stable api for csv format definition
115
- * [ ] assign attrs one at a time so they don't all fail together
303
+ * [x] public stable api for csv format definition
304
+ * [x] assign attrs one at a time so they don't all fail together
305
+ * [x] decide what empty cells mean
306
+ * [ ] figure out why SpeakyCsv is a class and not a module
116
307
 
117
308
  ## Contributing
118
309
 
data/lib/speaky_csv.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require 'active_support/all'
2
2
  require 'csv'
3
3
 
4
+ require 'speaky_csv/config'
5
+ require 'speaky_csv/config_builder'
6
+
4
7
  require 'speaky_csv/active_record_import'
5
8
  require 'speaky_csv/attr_import'
6
9
  require 'speaky_csv/base'
@@ -73,7 +73,7 @@ module SpeakyCsv
73
73
  end
74
74
 
75
75
  unless record
76
- logger.error "[row #{row_index}] record not found with primary key #{attrs[@config.primary_key]}"
76
+ logger.error "[row #{row_index}] record not found with primary key: #{attrs[@config.primary_key.to_s].inspect}"
77
77
  yielder << nil
78
78
  next
79
79
  end
@@ -89,11 +89,13 @@ module SpeakyCsv
89
89
  end
90
90
  end
91
91
 
92
- @config.has_manys.keys.each do |name|
93
- if attrs.key?(name.to_s)
94
- # assume nested attributes feature is used
95
- attrs["#{name}_attributes"] = attrs.delete name.to_s
96
- end
92
+ @config.has_manys.keys.map(&:to_s).select{|n| attrs.key? n}.each do |name|
93
+ # assume nested attributes feature is used
94
+ attrs["#{name}_attributes"] = attrs.delete name
95
+ end
96
+
97
+ @config.has_ones.keys.map(&:to_s).select{|n| attrs.key? n}.each do |name|
98
+ attrs["#{name}_attributes"] = attrs.delete name.to_s
97
99
  end
98
100
 
99
101
  attrs.each do |attr, value|
@@ -37,8 +37,10 @@ module SpeakyCsv
37
37
 
38
38
  csv.each do |row|
39
39
  attrs = {}
40
+ validate_headers row
40
41
  add_fields row, attrs
41
42
  add_has_manys row, attrs
43
+ add_has_ones row, attrs
42
44
  yielder << attrs
43
45
  end
44
46
 
@@ -48,19 +50,24 @@ module SpeakyCsv
48
50
  end
49
51
  end
50
52
 
51
- # Adds configured fields to attrs
52
- def add_fields(row, attrs)
53
- row.headers.compact.each do |h|
54
- unless @config.fields.include?(h.to_sym)
55
- logger.warn "ignoring unknown column #{h}"
56
- next
57
- end
58
- if @config.export_only_fields.include?(h.to_sym)
53
+ # TODO: don't warn on has_one headers and clean up clunky loop
54
+ def validate_headers(row)
55
+ valid_headers = @config.fields - @config.export_only_fields
56
+ #valid_headers += @config.has_ones.map
57
+
58
+ row.headers.compact.map(&:to_sym).each do |h|
59
+ unless valid_headers.include?(h)
59
60
  logger.warn "ignoring unknown column #{h}"
60
- next
61
61
  end
62
+ end
63
+ end
62
64
 
63
- attrs[h] = row.field h
65
+ # Adds configured fields to attrs
66
+ def add_fields(row, attrs)
67
+ fields = (@config.fields - @config.export_only_fields).map(&:to_s)
68
+ fields.each do |name|
69
+ row.has_key? name or next
70
+ attrs[name] = row.field name
64
71
  end
65
72
  end
66
73
 
@@ -90,5 +97,17 @@ module SpeakyCsv
90
97
  attrs[has_many_name][has_many_index][has_many_field] = has_many_value
91
98
  end
92
99
  end
100
+
101
+ # Adds configured has ones to attrs
102
+ def add_has_ones(row, attrs)
103
+ @config.has_ones.each do |name,assoc_config|
104
+ fields = (assoc_config.fields - assoc_config.export_only_fields).map(&:to_s)
105
+ fields.each do |f|
106
+ csv_name = "#{name}_#{f}"
107
+ row.has_key? csv_name or next
108
+ (attrs[name.to_s] ||= {})[f] = row.field "#{name}_#{f}"
109
+ end
110
+ end
111
+ end
93
112
  end
94
113
  end