csv_madness 0.0.4 → 0.0.9

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.
@@ -0,0 +1,77 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module ClassMethods
4
+ # Used to make getter/setter names out of the original header strings.
5
+ # " hello;: world! " => :hello_world
6
+ def getter_name( name )
7
+ # replace any run of non-word characters with a single "_"
8
+ name = name.downcase.gsub( /(\W|_|\s)+/, "_" )
9
+
10
+ # snip trailing and leading "_"
11
+ name = name.gsub( /(^_+|_+$)/, "" )
12
+
13
+ # add leading "_" when we'd otherwise have a method name starting with a digit
14
+ name = "_#{name}" if name.match( /^\d/ )
15
+
16
+ name.to_sym
17
+ end
18
+
19
+
20
+ # Paths to be searched when CsvMadness.load( "filename.csv" ) is called.
21
+ def add_search_path( path )
22
+ @search_paths ||= []
23
+ path = Pathname.new( path ).expand_path
24
+ unless path.directory?
25
+ raise "The given path does not exist"
26
+ end
27
+
28
+ @search_paths << path unless @search_paths.include?( path )
29
+ end
30
+
31
+ def search_paths
32
+ @search_paths
33
+ end
34
+
35
+ def from( csv_file, opts = {} )
36
+ if f = find_spreadsheet_in_filesystem( csv_file )
37
+ Sheet.new( f, opts )
38
+ else
39
+ raise "File not found."
40
+ end
41
+ end
42
+
43
+ # Search absolute/relative-to-current-dir before checking
44
+ # search paths.
45
+ def find_spreadsheet_in_filesystem( name )
46
+ @search_paths ||= []
47
+
48
+ expanded_path = Pathname.new( name ).expand_path
49
+ if expanded_path.exist?
50
+ return expanded_path
51
+ else # look for it in the search paths
52
+ @search_paths.each do |p|
53
+ file = p.join( name )
54
+ if file.exist? && file.file?
55
+ return p.join( name )
56
+ end
57
+ end
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ # opts are passed to underlying CSV (:row_sep, :encoding, :force_quotes)
64
+ def to_csv( spreadsheet, opts = {} )
65
+ out = spreadsheet.columns.to_csv( opts )
66
+ spreadsheet.records.inject( out ) do |output, record|
67
+ output << record.to_csv( opts )
68
+ end
69
+ end
70
+
71
+ def write_to_file( spreadsheet, file, opts = {} )
72
+ file = file.fwf_filepath.expand_path
73
+ file.write( spreadsheet.to_csv( opts ) )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,187 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module ColumnMethods
4
+
5
+ COLUMN_TYPES = {
6
+ number: Proc.new do |cell, record|
7
+ rval = cell
8
+
9
+ unless cell.nil? || (cell.is_a?(String) && cell.length == 0)
10
+
11
+ begin
12
+ rval = Integer(cell)
13
+ rescue
14
+ # do nothing
15
+ end
16
+
17
+ unless rval.is_a?(Integer)
18
+ begin
19
+ rval = Float(cell)
20
+ rescue
21
+ # do nothing
22
+ end
23
+ end
24
+ end
25
+
26
+ rval
27
+ end,
28
+
29
+ integer: Proc.new do |cell, record|
30
+ begin
31
+ Integer(cell)
32
+ rescue
33
+ cell
34
+ end
35
+ end,
36
+
37
+ float: Proc.new do |cell, record|
38
+ begin
39
+ Float(cell)
40
+ rescue
41
+ cell
42
+ end
43
+ end,
44
+
45
+ date: Proc.new do |cell, record|
46
+ begin
47
+ parse = Time.parse( cell || "" )
48
+ rescue ArgumentError
49
+ if cell =~ /^Invalid Time Format: /
50
+ parse = cell
51
+ else
52
+ parse = "Invalid Time Format: <#{cell}>"
53
+ end
54
+ end
55
+
56
+ parse
57
+ end
58
+ }
59
+
60
+ FORBIDDEN_COLUMN_NAMES = [:to_s] # breaks things hard when you use them. Probably not comprehensive, sadly.
61
+
62
+
63
+ def column col
64
+ @records.map(&col)
65
+ end
66
+
67
+ def has_column?( col )
68
+ self.columns.include?( col.to_sym )
69
+ end
70
+
71
+ # retrieve multiple columns. Returns an array of the form
72
+ # [ [record1:col1, record1:col2...], [record2:col1, record2:col2...] [...] ]
73
+ def multiple_columns(*args)
74
+ @records.inject([]){ |memo, record|
75
+ memo << args.map{ |arg| record.send(arg) }
76
+ memo
77
+ }
78
+ end
79
+
80
+ def alter_column( column, blank = :undefined, &block )
81
+ raise_on_missing_columns( column )
82
+
83
+ if cindex = @columns.index( column )
84
+ for record in @records
85
+ if record.blank?(column) && blank != :undefined
86
+ record[cindex] = blank
87
+ else
88
+ record[cindex] = yield( record[cindex], record )
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ # If no block given, adds an empty column.
95
+ # If block given, fills the new column cells when the block is run on each record
96
+ # TODO: Should be able to specify the index of the column
97
+ def add_column( column, &block )
98
+ raise_on_forbidden_column_names( column )
99
+ raise ColumnExistsError.new( "Column already exists: #{column}" ) if @columns.include?( column )
100
+
101
+ @columns << column
102
+
103
+ # add empty column to each row
104
+ @records.map{ |r|
105
+ r.csv_data << {column => ""}
106
+ }
107
+
108
+ update_data_accessor_module
109
+
110
+ if block_given?
111
+ alter_column( column ) do |val, record|
112
+ yield val, record
113
+ end
114
+ end
115
+ end
116
+
117
+ def drop_column( column )
118
+ raise_on_missing_columns( column )
119
+
120
+ @columns.delete( column )
121
+
122
+ key = column.to_s
123
+
124
+ @records.map{ |r|
125
+ r.csv_data.delete( key )
126
+ }
127
+
128
+ update_data_accessor_module
129
+ end
130
+
131
+ def rename_column( column, new_name )
132
+ @columns[@columns.index(column)] = new_name
133
+ rename_index_column( column, new_name ) if @index_columns.include?( column )
134
+ update_data_accessor_module
135
+ end
136
+
137
+ def set_column_type( column, type, blank = :undefined )
138
+ alter_column( column, blank, &COLUMN_TYPES[type] )
139
+ end
140
+
141
+
142
+ # If :reverse_merge is true, then the dest column is only overwritten for records where :dest is blank
143
+ def merge_columns( source, dest, opts = {} )
144
+ opts = { :drop_source => true, :reverse_merge => false, :default => "" }.merge( opts )
145
+ raise_on_missing_columns( source, dest )
146
+
147
+ self.records.each do |record|
148
+ if opts[:reverse_merge] == false || record.blank?( dest )
149
+ record[dest] = record.blank?(source) ? opts[:default] : record[source]
150
+ end
151
+ end
152
+
153
+ self.drop_column( source ) if opts[:drop_source]
154
+ end
155
+
156
+ # By default, the
157
+ def concat_columns( col1, col2, opts = {} )
158
+ opts = {:separator => '', :out => col1}.merge( opts )
159
+
160
+ raise_on_missing_columns( col1, col2 )
161
+ self.add_column( opts[:out] ) unless self.columns.include?( opts[:out] )
162
+
163
+ for record in self.records
164
+ record[ opts[:out] ] = "#{record[col1]}#{opts[:separator]}#{record[col2]}"
165
+ end
166
+ end
167
+
168
+ alias :concatenate :concat_columns
169
+
170
+ protected
171
+ def raise_on_missing_columns( *cols )
172
+ missing_columns = cols.flatten.reject{ |col| self.has_column?( col ) }
173
+
174
+ raise MissingColumnError.new( "#{caller[0]}: required_column(s) :#{missing_columns.inspect} not found." ) unless missing_columns.fwf_blank?
175
+ end
176
+
177
+ def forbidden_column_name?( col )
178
+ FORBIDDEN_COLUMN_NAMES.include?( col )
179
+ end
180
+
181
+ def raise_on_forbidden_column_names( *cols )
182
+ forbidden_columns = cols.flatten.select{ |col| self.forbidden_column_name?( col ) }
183
+ raise ForbiddenColumnNameError.new( "forbidden names: #{forbidden_columns.inspect}" ) unless forbidden_columns.fwf_blank?
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,41 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module FileMethods
4
+ def write_to_file( file, opts = {} )
5
+ self.class.write_to_file( self, file, opts )
6
+ end
7
+
8
+ # pass an arbitrary filepath to save sheet to that filepath
9
+ # call save() with no arguments to overwrite the current spreadsheet file
10
+ # call save(:timestamp) to make a timestamped copy of the spreadsheet file
11
+ #
12
+ # Obviously, the last two require self.spreadsheet_file to exist and be writable
13
+ def save( file = self.spreadsheet_file )
14
+ if file == :timestamp
15
+ if self.spreadsheet_file
16
+ file = self.spreadsheet_file.fwf_filepath.timestamp
17
+ else
18
+ raise ArgumentError.new( "CsvMadness.save(:timestamp) - Spreadsheet must be specified by @spreadsheet_file" )
19
+ end
20
+ end
21
+
22
+ if file
23
+ self.write_to_file( self.spreadsheet_file, @opts || {} )
24
+ else
25
+ raise "CsvMadness.save(:timestamp) - Spreadsheet must be specified by spreadsheet_file"
26
+ end
27
+ end
28
+
29
+
30
+ def reload_spreadsheet( opts = @opts )
31
+ load_csv if @spreadsheet_file
32
+ set_initial_columns( opts[:columns] )
33
+ create_record_class
34
+ package
35
+
36
+ set_index_columns( opts[:index] )
37
+ reindex
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,52 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module Indexing
4
+ def set_index_columns( index_columns )
5
+ @index_columns = case index_columns
6
+ when NilClass
7
+ []
8
+ when Symbol
9
+ [ index_columns ]
10
+ when Array
11
+ index_columns
12
+ end
13
+ end
14
+
15
+ def add_to_index( col, key, record )
16
+ (@indexes[col] ||= {})[key] = record
17
+ end
18
+
19
+ def add_to_indexes( records )
20
+ if records.is_a?( Array )
21
+ for record in records
22
+ add_to_indexes( record )
23
+ end
24
+ else
25
+ record = records
26
+ for col in @index_columns
27
+ add_to_index( col, record.send(col), record )
28
+ end
29
+ end
30
+ end
31
+
32
+ def remove_from_index( record )
33
+ for col in @index_columns
34
+ @indexes[col].delete( record.send(col) )
35
+ end
36
+ end
37
+
38
+ # Reindexes the record lookup tables.
39
+ def reindex
40
+ @indexes = {}
41
+ add_to_indexes( @records )
42
+ end
43
+
44
+ # shouldn't require reindex
45
+ def rename_index_column( column, new_name )
46
+ @index_columns[ @index_columns.index( column ) ] = new_name
47
+ @indexes[new_name] = @indexes[column]
48
+ @indexes.delete(column)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,99 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module RecordMethods
4
+ def add_record( record )
5
+ case record
6
+ when Array
7
+ # CSV::Row.new( column names, column_entries ) (in same order as columns, natch)
8
+ record = CSV::Row.new( self.columns, record )
9
+ when Hash
10
+ header = []
11
+ fields = []
12
+
13
+ for col in self.columns
14
+ header << col
15
+ fields << record[col]
16
+ end
17
+
18
+ record = CSV::Row.new( header, fields )
19
+ when CSV::Row
20
+ # do nothing
21
+ else
22
+ raise "sheet.add_record() doesn't take objects of type #{record.inspect}" unless record.respond_to?(:csv_data)
23
+ record = record.csv_data
24
+ end
25
+
26
+ record = @record_class.new( record, self.column_accessors_map )
27
+ @records << record
28
+ add_to_indexes( record )
29
+ end
30
+
31
+ alias :<< :add_record
32
+
33
+ def add_blank_record( &block )
34
+ self.add_record( {} )
35
+ if block_given?
36
+ yield self.records.last
37
+ else
38
+ self.records.last
39
+ end
40
+ end
41
+
42
+ # record can be the row number (integer from 0...@records.length)
43
+ # record can be the record itself (anonymous class)
44
+ def remove_record( record )
45
+ record = @records[record] if record.is_a?(Integer)
46
+ return if record.nil?
47
+
48
+ self.remove_from_index( record )
49
+ @records.delete( record )
50
+ end
51
+
52
+ # Here's the deal: you hand us a block, and we'll remove the records for which
53
+ # it yields _true_.
54
+ def remove_records( records = nil, &block )
55
+ if block_given?
56
+ for record in @records
57
+ remove_record( record ) if yield( record ) == true
58
+ end
59
+ else # records should be an array
60
+ for record in records
61
+ self.remove_record( record )
62
+ end
63
+ end
64
+ end
65
+
66
+ # Fetches an indexed record based on the column indexed and the keying object.
67
+ # If key is an array of keying objects, returns an array of records in the
68
+ # same order that the keying objects appear.
69
+ # Index column should yield a different, unique value for each record.
70
+ def fetch( index_col, key )
71
+ if key.is_a?(Array)
72
+ key.map{ |k| @indexes[index_col][k] }
73
+ else
74
+ @indexes[index_col][key]
75
+ end
76
+ end
77
+
78
+ # function should take an object, and return either true or false
79
+ # returns an array of objects that respond true when put through the
80
+ # meat grinder
81
+ def filter( &block )
82
+ rval = []
83
+ @records.each do |record|
84
+ rval << record if ( yield record )
85
+ end
86
+
87
+ rval
88
+ end
89
+
90
+ # removes rows which fail the given test from the spreadsheet.
91
+ # TODO! Should be returning self rather than the records.a
92
+ def filter!( &block )
93
+ @records = self.filter( &block )
94
+ reindex
95
+ @records
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/csv_madness.rb CHANGED
@@ -1,17 +1,7 @@
1
1
  require 'csv'
2
- require 'fun_with_files'
2
+ require 'fun_with_gems'
3
+ # require 'fun_with_version_strings'
3
4
  require 'time' # to use Time.parse to parse cells to get the date
4
- require 'debugger'
5
5
 
6
- require_relative 'csv_madness/data_accessor_module'
7
- require_relative 'csv_madness/sheet'
8
- require_relative 'csv_madness/record'
6
+ FunWith::Gems.make_gem_fun( "CsvMadness" )
9
7
 
10
-
11
- FunWith::Files::RootPath.rootify( CsvMadness, __FILE__.fwf_filepath.dirname.up )
12
-
13
- CsvMadness.class_eval do
14
- def self.load( csv, opts = {} )
15
- CsvMadness::Sheet.from( csv, opts )
16
- end
17
- end
@@ -0,0 +1,2 @@
1
+ "to_s"
2
+ "string!"
@@ -0,0 +1,11 @@
1
+ "id","fname","lname","party"
2
+ "1","Mary","Moore","D"
3
+ "2","Bill","Paxton","R"
4
+ "3","Charles","Darwin","I"
5
+ "4","Chuck","Norris","D"
6
+ "5","Annabelle","Lecter","R"
7
+ "6","Mortimer","Bradford","D"
8
+ "7","Wilford","Brimley","I"
9
+ "8","Cala","Wilcox","R"
10
+ "9","Horace","Wilcox","R"
11
+ "10","Jacob","Buford","D"
@@ -0,0 +1,3 @@
1
+ "id","number","integer","float","date"
2
+ ,,,,
3
+ 12,134.2,100,123.4,2013-01-13
@@ -0,0 +1,5 @@
1
+ "id","fname","lname","age","born"
2
+ "1",,"Moore",,"1986-04-08 15:06:10"
3
+ "2","Bill","Paxton","39","1974-02-22"
4
+ "3","Charles","Darwin",,"Invalid Date"
5
+ "4","Chuck","Norris","57"
data/test/helper.rb CHANGED
@@ -1,30 +1,41 @@
1
- require 'rubygems'
2
- require 'bundler'
3
-
4
- begin
5
- Bundler.setup(:default, :development)
6
- rescue Bundler::BundlerError => e
7
- $stderr.puts e.message
8
- $stderr.puts "Run `bundle install` to install missing gems"
9
- exit e.status_code
10
- end
11
-
12
- require 'test/unit'
13
- require 'shoulda'
1
+ # require 'rubygems'
2
+ # require 'bundler'
3
+ #
4
+ # begin
5
+ # Bundler.setup(:default, :development)
6
+ # rescue Bundler::BundlerError => e
7
+ # $stderr.puts e.message
8
+ # $stderr.puts "Run `bundle install` to install missing gems"
9
+ # exit e.status_code
10
+ # end
11
+ #
12
+ # require 'test/unit'
13
+ # require 'shoulda'
14
14
 
15
15
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
16
16
  $LOAD_PATH.unshift(File.dirname(__FILE__))
17
17
 
18
+ require 'fun_with_testing'
18
19
  require 'csv_madness'
19
20
 
20
- class Test::Unit::TestCase
21
- end
21
+ # class Test::Unit::TestCase
22
+ # end
22
23
 
23
- class MadTestCase < Test::Unit::TestCase
24
+ class MadTestCase < FunWith::Testing::TestCase # Test::Unit::TestCase
25
+ include FunWith::Testing::Assertions::Basics
26
+
24
27
  MARY_ID = "1"
25
28
  BILL_ID = "2"
26
29
  DARWIN_ID = "3"
27
30
  CHUCK_ID = "4"
31
+
32
+ def setup
33
+ set_spreadsheet_paths
34
+ end
35
+
36
+ def load_sheet( *args )
37
+ @sheet = CsvMadness.load( *args )
38
+ end
28
39
 
29
40
  def load_mary
30
41
  id = @simple.index_columns.first
@@ -54,6 +65,7 @@ class MadTestCase < Test::Unit::TestCase
54
65
  end
55
66
 
56
67
  def muck_up_spreadsheet
68
+ debugger
57
69
  @simple.add_column(:scrambled_name) do |val, record|
58
70
  record.fname.chars.map(&:to_s).zip( record.lname.chars.map(&:to_s) ).flatten.compact.join
59
71
  end
@@ -78,9 +90,9 @@ class MadTestCase < Test::Unit::TestCase
78
90
  end
79
91
 
80
92
  def set_spreadsheet_paths
81
- @csv_search_path = Pathname.new( __FILE__ ).dirname.join("csv")
82
- @csv_output_path = @csv_search_path.join("out")
83
- CsvMadness::Sheet.add_search_path( @csv_search_path )
93
+ @csv_load_path = CsvMadness.root( "test", "csv" )
94
+ @csv_output_path = CsvMadness.root( "test", "csv", "out" )
95
+ CsvMadness::Sheet.add_search_path( @csv_load_path )
84
96
  CsvMadness::Sheet.add_search_path( @csv_output_path )
85
97
  end
86
98
 
@@ -0,0 +1,37 @@
1
+ require 'helper'
2
+
3
+ class TestBuilder < MadTestCase
4
+ context "testing simple cases" do
5
+ should "spreadsheetize integers" do
6
+ integers = [65, 66, 67, 68, 69, 70]
7
+
8
+ sb = CsvMadness::Builder.new do |s|
9
+ s.column( :even, "even?" ) # calls the .even?() method on each object
10
+ s.column( :odd, "odd?" )
11
+ s.column( :hashh, "hash" )
12
+ s.column( :hashhash, "hash.hash" ) # calls .hash(), then calls .hash() on result
13
+ s.column( :chr )
14
+ s.column( :not_a_valid_method )
15
+ s.column( :square ) do |i| # Stores the block as a proc, runs on each object.
16
+ i * i
17
+ end
18
+ end
19
+
20
+ ss = sb.build( integers )
21
+
22
+ for record in ss.records
23
+ assert_kind_of( CsvMadness::Record, ss.records.first )
24
+ for col in [:even, :odd, :hashh, :hashhash, :chr]
25
+ assert_respond_to record, col
26
+ end
27
+ end
28
+
29
+ assert_matches ss.records.first.not_a_valid_method, /^ERROR: undefined method `not_a_valid_method'/
30
+
31
+ ss = sb.build( integers, :on_error => :ignore )
32
+
33
+ assert_equal "", ss.records.first.not_a_valid_method
34
+ assert_equal 4225, ss.records.first.square
35
+ end
36
+ end
37
+ end
@@ -3,7 +3,6 @@ require 'helper'
3
3
  class TestCsvMadness < MadTestCase
4
4
  context "all:" do
5
5
  setup do
6
- set_spreadsheet_paths
7
6
  load_simple_spreadsheet
8
7
  end
9
8
 
@@ -14,12 +13,12 @@ class TestCsvMadness < MadTestCase
14
13
  context "testing sheet basics" do
15
14
  should "not accept duplicate search paths" do
16
15
  @path_count = CsvMadness::Sheet.search_paths.length
17
- CsvMadness::Sheet.add_search_path( Pathname.new( __FILE__ ).dirname.join("csv") )
16
+ CsvMadness::Sheet.add_search_path( @csv_load_path )
18
17
  assert_equal @path_count, CsvMadness::Sheet.search_paths.length
19
18
  end
20
19
 
21
20
  should "load a simple spreadsheet" do
22
- simple = CsvMadness::Sheet.from("simple.csv")
21
+ simple = CsvMadness::Sheet.from( "simple.csv" )
23
22
  assert_equal "Mary", simple[0].fname
24
23
  assert_equal "Paxton", simple[1].lname
25
24
  assert_equal "72", simple[2].age
@@ -111,7 +110,7 @@ class TestCsvMadness < MadTestCase
111
110
  assert_in_delta Time.parse("1986-04-08"), born1, 3600 * 24
112
111
 
113
112
  assert_kind_of String, born3
114
- assert_match /Invalid Time Format/, born3
113
+ assert_match( /Invalid Time Format/, born3 )
115
114
  end
116
115
 
117
116
  should "successfully decorate record objects with new functionality" do
@@ -213,19 +212,18 @@ class TestCsvMadness < MadTestCase
213
212
 
214
213
  should "to_csv properly" do
215
214
  @to_csv = @nilsheet.to_csv( force_quotes: true )
216
- assert_match /"Moore"/, @to_csv
217
- assert_match /"age","born"/, @to_csv
215
+ assert_match( /"Moore"/, @to_csv )
216
+ assert_match( /"age","born"/, @to_csv )
218
217
  end
219
218
 
220
219
  should "write to an output file properly" do
221
- # debugger
222
220
  @outfile = @csv_output_path.join("output_nilfile.csv")
223
221
  @nilsheet.write_to_file( @outfile, force_quotes: true )
224
222
 
225
223
  assert File.exist?( @outfile )
226
224
  @to_csv = File.read( @outfile )
227
- assert_match /"Moore"/, @to_csv
228
- assert_match /"age","born"/, @to_csv
225
+ assert_match( /"Moore"/, @to_csv )
226
+ assert_match( /"age","born"/, @to_csv )
229
227
  end
230
228
  end
231
229
 
@@ -237,7 +235,7 @@ class TestCsvMadness < MadTestCase
237
235
 
238
236
  should "add column" do
239
237
  @simple.add_column( :compound ) do |h, record|
240
- v = "#{record.fname} #{record.lname} #{record.id}"
238
+ "#{record.fname} #{record.lname} #{record.id}"
241
239
  end
242
240
 
243
241
  load_mary