speaky_csv 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c9f27598188d01a1f54fc451cd37a7bf147030b8
4
+ data.tar.gz: b140a75185fbe65047c91b60dc041f11bcbe7f08
5
+ SHA512:
6
+ metadata.gz: bd17153298a4ace51d67c96698fc5454c5e27f93652ac651cdb3241779bb4347e177fc5385e044d08d14ecab326568db5f9fc4235791343e03ea6fa7a28d348f
7
+ data.tar.gz: ad3848f06899fca0b59bb0adb3e5534c4ea18886fec66cfd10e2ac07241a299fa0dae6807f774b2826281437a68102cb80230c8e3efb09268206198e8661c309
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ spec/active_record.log
data/.rubocop.yml ADDED
@@ -0,0 +1,27 @@
1
+ # uncomment to ignore pre-existing warnings
2
+ # inherit_from: .rubocop_todo.yml
3
+
4
+ AllCops:
5
+ Exclude:
6
+ - 'bin/**/*'
7
+ - 'db/**/*'
8
+ - 'config/**/*'
9
+ - 'features/**/*'
10
+ - 'lib/**/*'
11
+ - 'script/**/*'
12
+ - 'spec/**/*'
13
+ - 'vendor/**/*'
14
+
15
+ RunRailsCops: true
16
+
17
+ # Allow 120 character line lengths
18
+ LineLength:
19
+ Max: 120
20
+
21
+ # Allow tabbed alignment of method args
22
+ SingleSpaceBeforeFirstArg:
23
+ Enabled: false
24
+
25
+ # Don't require top-level documenation
26
+ Documentation:
27
+ Enabled: false
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.3
4
+ before_install: gem install bundler -v 1.10.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in speaky_csv.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,119 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ speaky_csv (0.0.1)
5
+ activemodel
6
+ activerecord
7
+ activesupport
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ activemodel (4.2.5)
13
+ activesupport (= 4.2.5)
14
+ builder (~> 3.1)
15
+ activerecord (4.2.5)
16
+ activemodel (= 4.2.5)
17
+ activesupport (= 4.2.5)
18
+ arel (~> 6.0)
19
+ activesupport (4.2.5)
20
+ i18n (~> 0.7)
21
+ json (~> 1.7, >= 1.7.7)
22
+ minitest (~> 5.1)
23
+ thread_safe (~> 0.3, >= 0.3.4)
24
+ tzinfo (~> 1.1)
25
+ arel (6.0.3)
26
+ ast (2.1.0)
27
+ astrolabe (1.3.1)
28
+ parser (~> 2.2)
29
+ builder (3.2.2)
30
+ coderay (1.1.0)
31
+ database_cleaner (1.5.1)
32
+ diff-lcs (1.2.5)
33
+ ffi (1.9.10)
34
+ formatador (0.2.5)
35
+ guard (2.13.0)
36
+ formatador (>= 0.2.4)
37
+ listen (>= 2.7, <= 4.0)
38
+ lumberjack (~> 1.0)
39
+ nenv (~> 0.1)
40
+ notiffany (~> 0.0)
41
+ pry (>= 0.9.12)
42
+ shellany (~> 0.0)
43
+ thor (>= 0.18.1)
44
+ guard-compat (1.2.1)
45
+ guard-rspec (4.6.4)
46
+ guard (~> 2.1)
47
+ guard-compat (~> 1.1)
48
+ rspec (>= 2.99.0, < 4.0)
49
+ i18n (0.7.0)
50
+ json (1.8.3)
51
+ listen (3.0.4)
52
+ rb-fsevent (>= 0.9.3)
53
+ rb-inotify (>= 0.9)
54
+ lumberjack (1.0.9)
55
+ method_source (0.8.2)
56
+ minitest (5.8.2)
57
+ nenv (0.2.0)
58
+ notiffany (0.0.8)
59
+ nenv (~> 0.1)
60
+ shellany (~> 0.0)
61
+ parser (2.2.3.0)
62
+ ast (>= 1.1, < 3.0)
63
+ powerpack (0.1.1)
64
+ pry (0.10.3)
65
+ coderay (~> 1.1.0)
66
+ method_source (~> 0.8.1)
67
+ slop (~> 3.4)
68
+ rainbow (2.0.0)
69
+ rake (10.4.2)
70
+ rb-fsevent (0.9.6)
71
+ rb-inotify (0.9.5)
72
+ ffi (>= 0.5.0)
73
+ rspec (3.3.0)
74
+ rspec-core (~> 3.3.0)
75
+ rspec-expectations (~> 3.3.0)
76
+ rspec-mocks (~> 3.3.0)
77
+ rspec-core (3.3.2)
78
+ rspec-support (~> 3.3.0)
79
+ rspec-expectations (3.3.1)
80
+ diff-lcs (>= 1.2.0, < 2.0)
81
+ rspec-support (~> 3.3.0)
82
+ rspec-mocks (3.3.2)
83
+ diff-lcs (>= 1.2.0, < 2.0)
84
+ rspec-support (~> 3.3.0)
85
+ rspec-support (3.3.0)
86
+ rubocop (0.35.0)
87
+ astrolabe (~> 1.3)
88
+ parser (>= 2.2.3.0, < 3.0)
89
+ powerpack (~> 0.1)
90
+ rainbow (>= 1.99.1, < 3.0)
91
+ ruby-progressbar (~> 1.7)
92
+ ruby-progressbar (1.7.5)
93
+ ruby_gntp (0.3.4)
94
+ shellany (0.0.1)
95
+ slop (3.6.0)
96
+ sqlite3 (1.3.11)
97
+ thor (0.19.1)
98
+ thread_safe (0.3.5)
99
+ tzinfo (1.2.2)
100
+ thread_safe (~> 0.1)
101
+
102
+ PLATFORMS
103
+ ruby
104
+
105
+ DEPENDENCIES
106
+ bundler (> 1.5)
107
+ database_cleaner
108
+ guard-rspec
109
+ rake
110
+ rb-fsevent
111
+ rb-inotify
112
+ rspec (> 2.14.0)
113
+ rubocop
114
+ ruby_gntp
115
+ speaky_csv!
116
+ sqlite3
117
+
118
+ BUNDLED WITH
119
+ 1.10.3
data/Guardfile ADDED
@@ -0,0 +1,5 @@
1
+ guard :rspec, cmd: 'rspec' do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
4
+ watch('spec/spec_helper.rb') { 'spec' }
5
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Andrew Hartford
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Speaky CSV
2
+
3
+ CSV exporting and importing for ActiveRecord and ActiveModel records.
4
+
5
+ Speaky lets the format of csv files to be customized, but it does
6
+ require certain conventions to be followed. At a high level, the csv
7
+ ends up looking similar to the way active record data gets serialized
8
+ into form parameters which will be familiar to many rails developers.
9
+ The advantage of this approach is that associated records be imported
10
+ and exported.
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ gem 'speaky_csv'
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install speaky_csv
25
+
26
+ ## Usage
27
+
28
+ Subclass SpeakyCsv::Base and define a csv format for an active
29
+ record class. For example:
30
+
31
+ # in app/csv/user_csv.rb
32
+ class UserCsv < SpeakyCsv::Base
33
+ define_csv_fields do |config|
34
+ config.field :id, :first_name, :last_name, :email
35
+
36
+ config.has_many :roles do |r|
37
+ r.field :role_name
38
+ end
39
+ end
40
+ end
41
+
42
+ See the rdoc for more details on how to configure the format.
43
+
44
+ Once the format is defined records can be exported like this:
45
+
46
+ $ exporter = UserCsv.new.exporter(User.all)
47
+ $ File.open('users.csv', 'w') { |io| exporter.each { |row| io.write row } }
48
+
49
+ ## Recommendations
50
+
51
+ * Add `id` and `_destroy` fields for active record models
52
+ * For associations, use `nested_attributes_for` and add `id` and
53
+ `_destroy` fields
54
+ * Use optimistic locking and add `lock_version` to csv
55
+
56
+ ## TODO
57
+
58
+ * [x] export only fields
59
+ * [x] configurable id field (key off an `external_id` for example)
60
+ * [x] export validations
61
+ * [x] attr import validations
62
+ * [x] active record import validations
63
+ * [ ] `has_one` associations
64
+ * [ ] required fields (make `lock_version` required for example)
65
+
66
+ ## Contributing
67
+
68
+ 1. Fork it ( http://github.com/ajh/speaky_csv/fork )
69
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
70
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
71
+ 4. Push to the branch (`git push origin my-new-feature`)
72
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,97 @@
1
+ require 'csv'
2
+ require 'active_record'
3
+
4
+ module SpeakyCsv
5
+ # Imports a csv file as unsaved active record instances
6
+ class ActiveRecordImport
7
+ include Enumerable
8
+
9
+ QUERY_BATCH_SIZE = 20
10
+ TRUE_VALUES = ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES
11
+
12
+ attr_accessor :errors
13
+
14
+ def initialize(config, input_io, klass)
15
+ @config = config
16
+ @errors = ActiveModel::Errors.new(self)
17
+ @klass = klass
18
+
19
+ @attr_import = AttrImport.new @config, input_io
20
+ @attr_import.errors = @errors
21
+ end
22
+
23
+ def each
24
+ errors.clear
25
+ block_given? ? enumerator.each { |a| yield a } : enumerator
26
+ end
27
+
28
+ private
29
+
30
+ def enumerator
31
+ Enumerator.new do |yielder|
32
+ attr_enumerator = @attr_import.each
33
+ done = false
34
+
35
+ row_index = 1
36
+
37
+ while done == false
38
+ rows = []
39
+
40
+ QUERY_BATCH_SIZE.times do
41
+ begin
42
+ rows << attr_enumerator.next
43
+ rescue StopIteration
44
+ done = true
45
+ end
46
+ end
47
+
48
+ keys = rows.map { |attrs| attrs[@config.primary_key.to_s] }
49
+ records = @klass.includes(@config.has_manys.keys)
50
+ .where(@config.primary_key => keys)
51
+ .inject({}) { |a, e| a[e.send(@config.primary_key).to_s] = e; a }
52
+
53
+ rows.each do |attrs|
54
+ row_index += 1
55
+
56
+ record = if attrs[@config.primary_key.to_s].present?
57
+ records[attrs[@config.primary_key.to_s]]
58
+ else
59
+ @klass.new
60
+ end
61
+
62
+ unless record
63
+ errors.add "row_#{row_index}", "record not found with primary key #{attrs[@config.primary_key]}"
64
+ next
65
+ end
66
+
67
+ if @config.fields.include?(:_destroy)
68
+ if TRUE_VALUES.include?(attrs['_destroy'])
69
+ record.mark_for_destruction
70
+ yielder << record
71
+ next
72
+
73
+ else
74
+ attrs.delete '_destroy'
75
+ end
76
+ end
77
+
78
+ @config.has_manys.keys.each do |name|
79
+ if attrs.key?(name.to_s)
80
+ # assume nested attributes feature is used
81
+ attrs["#{name}_attributes"] = attrs.delete name.to_s
82
+ end
83
+ end
84
+
85
+ begin
86
+ record.attributes = attrs
87
+ rescue ActiveRecord::UnknownAttributeError
88
+ errors.add "row_#{row_index}", "record doesn't respond to some configured fields: #{$!.message}"
89
+ end
90
+
91
+ yielder << record
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,71 @@
1
+ require 'csv'
2
+
3
+ module SpeakyCsv
4
+ # Imports a csv file as attribute hashes.
5
+ class AttrImport
6
+ include Enumerable
7
+
8
+ attr_accessor :errors
9
+
10
+ def initialize(config, input_io)
11
+ @config = config
12
+ @input_io = input_io
13
+ @errors = ActiveModel::Errors.new(self)
14
+ end
15
+
16
+ # yields successive
17
+ def each
18
+ errors.clear
19
+ block_given? ? enumerator.each { |a| yield a } : enumerator
20
+ end
21
+
22
+ private
23
+
24
+ def enumerator
25
+ Enumerator.new do |yielder|
26
+ begin
27
+ csv = CSV.new @input_io, headers: true
28
+
29
+ csv.each do |row|
30
+ attrs = {}
31
+
32
+ row.headers.compact.each do |h|
33
+ next unless @config.fields.include?(h.to_sym)
34
+ next if @config.export_only_fields.include?(h.to_sym)
35
+ attrs[h] = row.field h
36
+ end
37
+
38
+ headers_length = row.headers.compact.length
39
+ pairs_start_on_evens = headers_length.even?
40
+ (headers_length..row.fields.length).each do |i|
41
+ i.send(pairs_start_on_evens ? :even? : :odd?) || next
42
+ row[i] || next
43
+
44
+ m = row[i].match(/^(\w+)_(\d+)_(\w+)$/)
45
+ m || next
46
+ has_many_name = m[1].pluralize
47
+ has_many_index = m[2].to_i
48
+ has_many_field = m[3]
49
+ has_many_value = row[i + 1]
50
+
51
+ has_many_config = @config.has_manys[has_many_name.to_sym]
52
+
53
+ next unless has_many_config
54
+ next unless has_many_config.fields.include?(has_many_field.to_sym)
55
+ next if has_many_config.export_only_fields.include?(has_many_field.to_sym)
56
+
57
+ attrs[has_many_name] ||= []
58
+ attrs[has_many_name][has_many_index] ||= {}
59
+ attrs[has_many_name][has_many_index][has_many_field] = has_many_value
60
+ end
61
+
62
+ yielder << attrs
63
+ end
64
+
65
+ rescue CSV::MalformedCSVError
66
+ errors.add :csv, "is malformed: #{$!.message}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,95 @@
1
+ module SpeakyCsv
2
+
3
+ # An instance of this class is yielded to the block passed to
4
+ # define_csv_fields. Used to configure speaky csv.
5
+ class Builder
6
+ attr_reader \
7
+ :export_only_fields,
8
+ :fields,
9
+ :has_manys,
10
+ :has_ones,
11
+ :primary_key
12
+
13
+ def initialize
14
+ @export_only_fields = []
15
+ @fields = []
16
+ @has_manys = {}
17
+ @has_ones = {}
18
+ @primary_key = :id
19
+ end
20
+
21
+ # Add one or many fields to the csv format.
22
+ #
23
+ # If options are passed, they apply to all given fields.
24
+ def field(*fields, export_only: false)
25
+ @fields += fields.map(&:to_sym)
26
+ @fields.uniq!
27
+
28
+ if export_only
29
+ @export_only_fields += fields.map(&:to_sym)
30
+ @export_only_fields.uniq!
31
+ end
32
+
33
+ nil
34
+ end
35
+
36
+ # Define a custom primary key. By default an `id` column as used.
37
+ #
38
+ # Accepts the same options as #field
39
+ def primary_key=(name, options={})
40
+ field name, options
41
+ @primary_key = name.to_sym
42
+ end
43
+
44
+ def has_one(name)
45
+ @has_ones[name.to_sym] ||= self.class.new
46
+ yield @has_ones[name.to_sym]
47
+
48
+ nil
49
+ end
50
+
51
+ def has_many(name)
52
+ @has_manys[name.to_sym] ||= self.class.new
53
+ yield @has_manys[name.to_sym]
54
+
55
+ nil
56
+ end
57
+
58
+ def dup
59
+ other = super
60
+ other.instance_variable_set '@has_manys', @has_manys.deep_dup
61
+ other.instance_variable_set '@has_ones', @has_ones.deep_dup
62
+
63
+ other
64
+ end
65
+ end
66
+
67
+ # Inherit from this class when using SpeakyCsv
68
+ class Base
69
+ class_attribute :csv_field_builder
70
+ self.csv_field_builder = Builder.new
71
+
72
+ def self.define_csv_fields
73
+ self.csv_field_builder = csv_field_builder.deep_dup
74
+ yield csv_field_builder
75
+ end
76
+
77
+ # Return a new exporter instance
78
+ def exporter(records_enumerator)
79
+ Export.new self.class.csv_field_builder,
80
+ records_enumerator
81
+ end
82
+
83
+ def attr_importer(input_io)
84
+ AttrImport.new self.class.csv_field_builder,
85
+ input_io
86
+ end
87
+
88
+ def active_record_importer(input_io, klass)
89
+ ActiveRecordImport.new \
90
+ self.class.csv_field_builder,
91
+ input_io,
92
+ klass
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,64 @@
1
+ require 'csv'
2
+ require 'active_model'
3
+
4
+ module SpeakyCsv
5
+ # Exports records as csv. Will write a csv to the given IO object
6
+ class Export
7
+ include Enumerable
8
+
9
+ def initialize(config, records_enumerator)
10
+ @config = config
11
+ @records_enumerator = records_enumerator
12
+ end
13
+
14
+ # Writes csv string to io
15
+ def each
16
+ errors.clear
17
+ block_given? ? enumerator.each { |a| yield a } : enumerator
18
+ end
19
+
20
+ def errors
21
+ @errors ||= ActiveModel::Errors.new(self)
22
+ end
23
+
24
+ private
25
+
26
+ def valid_field?(record, field, prefix: nil)
27
+ return true if record.respond_to? field
28
+
29
+ error_name = prefix ? "#{prefix}_#{field}" : field
30
+
31
+ if errors[error_name].blank?
32
+ errors.add error_name, "is not a method for class #{record.class}"
33
+ end
34
+
35
+ false
36
+ end
37
+
38
+ def enumerator
39
+ Enumerator.new do |yielder|
40
+ # header row
41
+ yielder << CSV::Row.new(@config.fields, @config.fields, true).to_csv
42
+
43
+ @records_enumerator.each do |record|
44
+ values = @config.fields
45
+ .select { |f| valid_field? record, f }
46
+ .map { |f| record.send f }
47
+
48
+ row = CSV::Row.new @config.fields, values
49
+
50
+ @config.has_manys.select { |a| valid_field? record, a }.each do |name, config|
51
+ record.send(name).each_with_index do |has_many_record, index|
52
+ config.fields.select { |f| valid_field? has_many_record, f, prefix: name }.each do |field|
53
+ row << "#{name.to_s.singularize}_#{index}_#{field}"
54
+ row << has_many_record.send(field)
55
+ end
56
+ end
57
+ end
58
+
59
+ yielder << row.to_csv
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,3 @@
1
+ module SpeakyCsv
2
+ VERSION = '0.0.1'
3
+ end
data/lib/speaky_csv.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'active_support/all'
2
+ require 'csv'
3
+
4
+ require 'speaky_csv/active_record_import'
5
+ require 'speaky_csv/attr_import'
6
+ require 'speaky_csv/base'
7
+ require 'speaky_csv/export'
8
+ require 'speaky_csv/version'
9
+
10
+ module SpeakyCsv
11
+ end
@@ -0,0 +1,38 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'speaky_csv/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'speaky_csv'
8
+ spec.version = SpeakyCsv::VERSION
9
+ spec.authors = ['Andy Hartford']
10
+ spec.email = ['andy.hartford@cohealo.com']
11
+ spec.summary = 'CSV importing and exporting for ActiveRecord and ActiveModel'
12
+ spec.description = 'CSV importing and exporting for ActiveRecord and ActiveModel with a Enumerator flavor'
13
+ spec.homepage = 'https://github.com/ajh/speaky_csv'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_runtime_dependency 'activemodel', '~> 4.2'
22
+ spec.add_runtime_dependency 'activerecord', '~> 4.2'
23
+ spec.add_runtime_dependency 'activesupport', '~> 4.2'
24
+
25
+ spec.add_development_dependency 'bundler', '~> 1.10'
26
+ spec.add_development_dependency 'database_cleaner', '~> 1.5'
27
+ spec.add_development_dependency 'rake', '~> 10.0'
28
+ spec.add_development_dependency 'rspec', '~> 3'
29
+ spec.add_development_dependency 'rspec-its', '~> 1'
30
+ spec.add_development_dependency 'rubocop', '~> 0.35'
31
+ spec.add_development_dependency 'sqlite3', '~> 1.3'
32
+
33
+ # guard stuff
34
+ spec.add_development_dependency 'guard-rspec', '~> 4.6'
35
+ spec.add_development_dependency 'rb-fsevent', '~> 0.9'
36
+ spec.add_development_dependency 'rb-inotify', '~> 0.9'
37
+ spec.add_development_dependency 'ruby_gntp', '~> 0.3'
38
+ end