speaky_csv 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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