README.md in speaky_csv-0.0.3 vs README.md in speaky_csv-0.0.5

- old
+ new

@@ -1,119 +1,310 @@ # Speaky CSV CSV imports and exports for ActiveRecord. -## For example +Speaky CSV features: -Lets say there exists a User class: +* An easy to use API, +* Speedy stream processing using enumerators. - # in app/models/user.rb - class User < ActiveRecord::Base - ... - end +## Installation -Speaky can be used to import and export user records. The definition of -the csv format could look like this: +Add this line to your application's Gemfile: - # in app/csv/user_csv.rb - class UserCsv < SpeakyCsv::Base - define_csv_fields do |config| - config.field :id, :email, :roles - end - end + gem 'speaky_csv' -Now lets import some user records. An import csv will always have an -initial header row, with each following row representing a user record. -lets import the following csv file (whitespace for clarity): +And then execute: - # my_import.csv - id, email, roles - 22, admin@example.test, admin - , newbie@user.test, user + $ bundle -This file can be imported like this: +Or install it yourself as: - File.open "my_import.csv", "r" do |io| - importer = UserCsv.new.active_record_importer io, User - importer.each { |user| user.save } + $ gem install speaky_csv + +## Usage + +Let's say you build software for your local public library and there +exists a Book model: + +```ruby +# in app/models/book.rb +class Book < ActiveRecord::Base + # ... +end +``` + +Speaky can be used to import and export book records using csv files. +The definition of the csv format could look like this: + +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author + end +end +``` + +This defines a CSV format that looks like this: + + id,author + 3,Stevenson + 19,Melville + 1,Macaulay + +## Exporting + +Creating a csv file from records in a database can be done with the +exporter: + +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author, :_destroy + end +end + +books = [ + Book.create!(author: 'Stevenson'), + Book.create!(author: 'Melville'), + Book.create!(author: 'Macaulay'), +] + +exporter = BookCsv.exporter books + +io = StringIO.new +exporter.each { |row| io.write row } +``` + +`io` will have the following contents: + + id,author,_destroy + 2,Stevenson,false + 3,Melville,false + 4,Macaulay,false + +##### With Associations + +Associations can also be exported. + +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author + + config.belongs_to :publisher do |p| + p.field :id, :name end -## Custom CSV formats + config.has_many :reviews do |r| + r.field :id, :tomatoes, :publication + end + end +end +``` -Speaky +This defines a CSV format that looks like this: + id,author,publisher_id,publisher_name + 3,Stevenson,22,Blam Ltd + 19,Melville,, + 1,Macaulay,83,NY Tiempo,reviews_0_id,8,reviews_0_tomatoes,50,review_0_publication,Daily -Speaky allows customization of csv files to a degree, but some -conventions need to be followed. +Since a book only ever has one publisher, these can get dedicated +columns with headers (the `publisher_id` and `publisher_name` columns). +Reviews are more tricky because there can be several that need to be +serialized to a single csv row. Speaky CSV uses a convention similar to +how rails and rack deal with query parameters for things like multi +select form inputs. -At a high level, the csv -ends up looking similar to the way active record data gets serialized -into form parameters which will be familiar to many rails developers. -The advantage of this approach is that associated records be imported -and exported. +## Importing -## Installation +Now lets import some books. Speaky will expect an import to have +an initial header row, and each subsequent row to represent a user record. +Columns can be in any order. -Add this line to your application's Gemfile: +Let's create a book by importing a csv. - gem 'speaky_csv' +```ruby +csv_io = StringIO.new <<-CSV +id,author +,Sneed +CSV +``` -And then execute: +Notice the empty id column. This tells Speaky that the operation is +a create. The file can be imported like this: - $ bundle +```ruby +importer = BookCsv.active_record_importer csv_io, Book +importer.each { |book| book.save } +Book.last.author == 'Sneed' # => true +``` -Or install it yourself as: +This importer is an `active record importer`, which means that `#each` +will return active record instances. There is also an `attribute importer` +that will return hashes of attribute name => values. See the rdoc for +more info on that. - $ gem install speaky_csv +##### Update -## Usage +Let's change the author value: -Subclass SpeakyCsv::Base and define a csv format for an active -record class. For example: +```ruby +csv_io = StringIO.new <<-CSV +id,author +1,Simon Sneed +CSV +``` - # in app/csv/user_csv.rb - class UserCsv < SpeakyCsv::Base - define_csv_fields do |config| - config.field :id, :first_name, :last_name, :email +Now there is an id value in the csv. Having an id value will cause +Speaky to find the record with the given id and update it. - config.has_many :roles do |r| - r.field :role_name - end - end +```ruby +importer = BookCsv.active_record_importer csv_io, Book +importer.each { |book| book.save } +expect(Book.last.author).to eq 'Simon Sneed' +``` + +If a record with the given id isn't found, the importer will return a +nil for that row instead of an active record and add a message a log +file: + +```ruby +csv_io = StringIO.new <<-CSV +id,author +234,I dont exist +CSV + +importer = BookCsv.active_record_importer csv_io, Book +importer.to_a # => [nil] +importer.log # => '...[row 1] record not found with primary key: "234"....' +``` + +For more info on the log file see below. + +##### Destroy + +To destroy the record, we'll need to change the csv format to add a +`_destroy` field. If this column contains a true value like: 'true' or +'1', the record will be marked for destruction. + +Marking an active record for destruction is documented here: +http://api.rubyonrails.org/v4.2.0/classes/ActiveRecord/AutosaveAssociation.html#method-i-marked_for_destruction-3F + +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author, :_destroy + end +end + +csv_io = StringIO.new <<-CSV +id,_destroy +1,true +CSV + +importer = BookCsv.active_record_importer csv_io, Book +book = importer.to_a.first +if book.marked_for_destruction? + book.destroy +end +``` + +##### With Associations + +Speaky uses the active record `accepts_nested_attributes_for` feature to +deal with importing association data. + +For example, if a belongs\_to association is configured: + +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author + + config.belongs_to :publisher do |p| + p.field :id, :name end + end +end +``` -See the rdoc for more details on how to configure the format. +And the csv file being imported is this: -Once the format is defined records can be exported like this: + id,author,publisher_id,publisher_name + 3,Stevenson,22,Blam Ltd - $ exporter = UserCsv.new.exporter(User.all) - $ File.open('users.csv', 'w') { |io| exporter.each { |row| io.write row } } +Then speaky will find a Booking record with id `3` and call: -TODO: +```ruby +booking.publisher_attributes = {id: '22', name: 'Blam Ltd'} +``` -* describe importing to attribute list -* describe importing to to active records -* describe how to transform with an enumerator +For a has\_many association, if the configuration looked like this: -## Recommendations +```ruby +# in app/csvs/book_csv.rb +class BookCsv < SpeakyCsv::Base + define_csv_fields do |config| + config.field :id, :author -* Add `id` and `_destroy` fields for active record models + config.has_many :reviews do |r| + r.field :id, :tomatoes, :publication + end + end +end +``` + +And an import csv looked like this: + + id,author,publisher_id,publisher_name + 1,Macaulay,83,NY Tiempo,reviews_0_id,8,reviews_0_tomatoes,50,review_0_publication,Daily + +The speaky will find a Booking record with id `1` and call: + +```ruby +booking.reviews_attributes = [{id: '8', tomatoes: '50', publication: 'Daily'}] +``` + +## Log Messages + +Importers and exporters use a `Logger` instance to write messages during +processing. The default logger writes to a string that can be retrieved +by the `#log` method. A custom Logger can be set by the `#logger=` +method. + +See `Logger` in the ruby stdlib for more details. + +## Best Practices + +* Configure speaky with `id` and `_destroy` fields for active record models * For associations, use `nested_attributes_for` and add `id` and `_destroy` fields -* Use optimistic locking and add `lock_version` to csv +* Use optimistic locking and configure a `lock_version` field +* Consider building a draft or preview feature for importing which + doesn't persist the record by calling `save` but instead reports what + the changes would be using `ActiveModel::Dirty` ## TODO * [x] export only fields * [x] configurable id field (key off an `external_id` for example) * [x] export validations * [x] attr import validations * [x] active record import validations -* [ ] `has_one` associations +* [x] `has_one` associations * [ ] required fields (make `lock_version` required for example) * [ ] transformations for values via accessors on class -* [ ] public stable api for csv format definition -* [ ] assign attrs one at a time so they don't all fail together +* [x] public stable api for csv format definition +* [x] assign attrs one at a time so they don't all fail together +* [x] decide what empty cells mean +* [ ] figure out why SpeakyCsv is a class and not a module ## Contributing 1. Fork it ( http://github.com/ajh/speaky_csv/fork ) 2. Create your feature branch (`git checkout -b my-new-feature`)