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.
@@ -1,44 +1,79 @@
1
+ # Each record within the spreadsheet is extended by a module with accessor functions
2
+ # which allow the user to treat column names as methods. When columns are added, dropped, or re-ordered,
3
+ # the accessor module needs to be updated to reflect the change.
1
4
  class DataAccessorModule < Module
5
+ # mapping : keys = column symbol
6
+ # values = column index
7
+ attr_accessor :column_accessors_map
8
+
2
9
  def initialize( mapping )
3
- @column_accessors = []
4
- remap_accessors( mapping )
10
+ puts "Got mapping #{mapping.inspect}"
11
+ @column_accessors_map = mapping
12
+ remap_accessors
5
13
  end
6
14
 
7
- def install_column_accessors( column, index )
8
- @column_accessors << column
9
- eval <<-EOF
10
- self.send( :define_method, :#{column} ) do
11
- self.csv_data[#{index}]
15
+ def install_column_accessors( *columns )
16
+ for column in columns.flatten
17
+ unless is_valid_ruby_method?( column )
18
+ warn( "#{column.inspect} is not a valid ruby method" )
19
+ require "data_accessor_module" # TODO: WTF?
20
+ column = "csv_column_#{index}"
21
+ warn( " ----> renaming to #{column.inspect}" )
12
22
  end
23
+
24
+ if self.respond_to?( column )
25
+ puts "Column already has accessors"
26
+ else
27
+ eval <<-EOF
28
+ self.send( :define_method, :#{column} ) do
29
+ self.csv_data[ self.column_accessors_map[ :#{column} ] ]
30
+ end
13
31
 
14
- self.send( :define_method, :#{column}= ) do |val|
15
- self.csv_data[#{index}] = val
32
+ self.send( :define_method, :#{column}= ) do |val|
33
+ self.csv_data[ self.column_accessors_map[ :#{column} ] ] = val
34
+ end
35
+ EOF
16
36
  end
17
- EOF
37
+ end
18
38
  end
19
39
 
20
- def remove_column_accessors( column )
21
- self.send( :remove_method, column )
22
- self.send( :remove_method, :"#{column}=" )
40
+ # But if instead of hardcoding the index, the index was pulled from the spreadsheet mapping...
41
+ # a titch slower to look up? But a change to the mapping automatically updates the method
42
+ def remove_column_accessors( *columns )
43
+ for column in columns.flatten
44
+ for method_sym in [column, :"#{column}="]
45
+ self.send( :remove_method, method_sym ) if self.respond_to?( method_sym )
46
+ end
47
+ end
23
48
  end
24
49
 
25
50
  def remove_all_column_accessors
26
- @column_accessors ||= []
51
+ # 2016-11-24 : was this necessary for anything
52
+ # @column_accessors ||= []
53
+ remove_column_accessors( @column_accessors_map.keys )
54
+ end
55
+
56
+ # Partly obsoleted.
57
+ def remap_accessors( *args )
58
+ #
27
59
 
28
- for sym in @column_accessors
29
- remove_column_accessors( sym )
60
+
61
+ remove_all_column_accessors
62
+
63
+ if args.length == 1
64
+ debugger
65
+ @column_accessors_map = args.first
30
66
  end
31
67
 
32
- @column_accessors = []
68
+ install_column_accessors( @column_accessors_map.keys )
33
69
  end
34
70
 
35
- def remap_accessors( mapping )
36
- remove_all_column_accessors
37
-
38
- @mapping = mapping
71
+ # Symbol.inspect doesn't output " quotes when given a sym
72
+ # that expresses a valid method name or a valid identifier.
73
+ def is_valid_ruby_method?( str_or_sym )
74
+ sym = str_or_sym.to_sym
75
+ test_string = sym.inspect
39
76
 
40
- for column, index in @mapping
41
- install_column_accessors( column, index )
42
- end
77
+ test_string.match( /^:[@"]/ ).nil?
43
78
  end
44
79
  end
@@ -0,0 +1,16 @@
1
+ module CsvMadness
2
+ class LoadError < RuntimeError
3
+ end
4
+
5
+ class ColumnError < ArgumentError
6
+ end
7
+
8
+ class ForbiddenColumnNameError < ColumnError
9
+ end
10
+
11
+ class MissingColumnError < ColumnError
12
+ end
13
+
14
+ class ColumnExistsError < ColumnError
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ module CsvMadness
2
+ module GemAPI
3
+ def load( csv, opts = {} )
4
+ CsvMadness::Sheet.from( csv, opts )
5
+ end
6
+
7
+ def build( objects, &block )
8
+ builder = CsvMadness::Builder.new(&block)
9
+ builder.build( objects )
10
+ end
11
+ end
12
+ end
@@ -8,8 +8,11 @@ module CsvMadness
8
8
  # symbol.
9
9
  class Record
10
10
  attr_accessor :csv_data
11
- def initialize( data )
12
- @csv_data = data
11
+ attr_reader :column_accessors_map
12
+
13
+ def initialize( data, mapping )
14
+ @column_accessors_map = mapping # holds a reference to its spreadsheet's overall mapping
15
+ import_record_data( data )
13
16
  end
14
17
 
15
18
  def [] key
@@ -34,6 +37,10 @@ module CsvMadness
34
37
  self.class.spreadsheet.columns
35
38
  end
36
39
 
40
+ def self.columns
41
+ self.spreadsheet.columns
42
+ end
43
+
37
44
  def self.spreadsheet= sheet
38
45
  @spreadsheet = sheet
39
46
  end
@@ -46,8 +53,40 @@ module CsvMadness
46
53
  self.columns.map{|col| self.send(col) }.to_csv( opts )
47
54
  end
48
55
 
56
+ def to_hash
57
+ self.columns.inject({}){ |hash, col| hash[col] = self.send( col ); hash }
58
+ end
59
+
60
+ def to_a
61
+ self.to_hash.to_a
62
+ end
63
+
49
64
  def blank?( col )
50
65
  (self.send( col.to_sym ).to_s || "").strip.length == 0
51
66
  end
67
+
68
+ protected
69
+ def import_record_data( data )
70
+ case data
71
+ when Array
72
+ csv_data = CSV::Row.new( self.columns, data )
73
+ when Hash
74
+ fields = self.columns.map do |col|
75
+ data[col]
76
+ end
77
+ # for col in self.columns
78
+ # fields << data[col]
79
+ # end
80
+
81
+ csv_data = CSV::Row.new( self.columns, fields )
82
+ when CSV::Row
83
+ csv_data = data
84
+ else
85
+ raise "record.import_record_data() doesn't take objects of type #{data.inspect}" unless data.respond_to?(:csv_data)
86
+ csv_data = data.csv_data.clone
87
+ end
88
+
89
+ @csv_data = csv_data
90
+ end
52
91
  end
53
92
  end
@@ -1,114 +1,16 @@
1
1
  module CsvMadness
2
2
  class Sheet
3
- COLUMN_TYPES = {
4
- number: Proc.new do |cell, record|
5
- if (cell || "").strip.match(/^\d*$/)
6
- cell.to_i
7
- else
8
- cell.to_f
9
- end
10
- end,
11
-
12
- integer: Proc.new do |cell, record|
13
- cell.to_i
14
- end,
15
-
16
- float: Proc.new do |cell, record|
17
- cell.to_f
18
- end,
19
-
20
- date: Proc.new do |cell, record|
21
- begin
22
- parse = Time.parse( cell )
23
- rescue ArgumentError
24
- parse = "Invalid Time Format: <#{cell}>"
25
- end
26
- parse
27
- end
28
- }
29
-
30
- # Used to make getter/setter names out of the original header strings.
31
- # " hello;: world! " => :hello_world
32
- def self.getter_name( name )
33
- name = name.strip.gsub( /\s+/, "_" ).gsub( /(\W|_)+/, "_" ).downcase
34
- name = name.gsub( /_+$/, "" )
35
- if name.match( /^\d/ )
36
- name = "_#{name}"
37
- end
38
-
39
- name.to_sym
40
- end
41
-
42
-
43
- # Paths to be searched when CsvMadness.load( "filename.csv" ) is called.
44
- def self.add_search_path( path )
45
- @search_paths ||= []
46
- path = Pathname.new( path ).expand_path
47
- unless path.directory?
48
- raise "The given path does not exist"
49
- end
50
-
51
- @search_paths << path unless @search_paths.include?( path )
52
- end
53
-
54
- def self.search_paths
55
- @search_paths
56
- end
3
+ extend SheetMethods::ClassMethods
4
+ include SheetMethods::Base
5
+ include SheetMethods::Indexing
6
+ include SheetMethods::FileMethods
7
+ include SheetMethods::ColumnMethods
8
+ include SheetMethods::RecordMethods
57
9
 
58
- def self.from( csv_file, opts = {} )
59
- if f = find_spreadsheet_in_filesystem( csv_file )
60
- Sheet.new( f, opts )
61
- else
62
- raise "File not found."
63
- end
64
- end
65
10
 
66
- # Search absolute/relative-to-current-dir before checking
67
- # search paths.
68
- def self.find_spreadsheet_in_filesystem( name )
69
- @search_paths ||= []
70
-
71
- expanded_path = Pathname.new( name ).expand_path
72
- if expanded_path.exist?
73
- return expanded_path
74
- else # look for it in the search paths
75
- @search_paths.each do |p|
76
- file = p.join( name )
77
- if file.exist? && file.file?
78
- return p.join( name )
79
- end
80
- end
81
- end
82
-
83
- nil
84
- end
11
+ attr_reader :columns, :index_columns, :record_class, :opts
12
+ attr_accessor :spreadsheet_file
85
13
 
86
- # opts are passed to underlying CSV (:row_sep, :encoding, :force_quotes)
87
- def self.to_csv( spreadsheet, opts = {} )
88
- out = spreadsheet.columns.to_csv( opts )
89
- spreadsheet.records.inject( out ) do |output, record|
90
- output << record.to_csv( opts )
91
- end
92
- end
93
-
94
- def self.write_to_file( spreadsheet, file, opts = {} )
95
- file = file.fwf_filepath.expand_path
96
- File.open( file, "w" ) do |f|
97
- f << spreadsheet.to_csv( opts )
98
- end
99
- end
100
-
101
- def write_to_file( file, opts = {} )
102
- self.class.write_to_file( self, file, opts )
103
- end
104
-
105
- def to_csv( opts = {} )
106
- self.records.inject( self.columns.to_csv( opts ) ) do |output, record|
107
- output << record.to_csv( opts )
108
- end
109
- end
110
-
111
- attr_reader :columns, :index_columns, :records, :spreadsheet_file, :record_class
112
14
  # opts:
113
15
  # index: ( [:id, :id2 ] )
114
16
  # columns you want mapped for quick
@@ -123,88 +25,90 @@ module CsvMadness
123
25
  #
124
26
  # header: false
125
27
  # anything else, we assume the csv file has a header row
126
- def initialize( spreadsheet, opts = {} )
127
- if spreadsheet.is_a?(Array)
128
- @spreadsheet_file = nil
129
-
28
+ def initialize( *args )
29
+ @csv = nil
30
+
31
+ if args.last.is_a?(Hash)
32
+ @opts = args.pop
130
33
  else
131
- @spreadsheet_file = self.class.find_spreadsheet_in_filesystem( spreadsheet )
34
+ @opts = {}
132
35
  end
133
- @opts = opts
36
+
37
+ firstarg = args.shift
38
+
39
+ case firstarg
40
+ when NilClass
41
+ @spreadsheet_file = nil
42
+ @opts[:columns] ||= []
43
+ when String, FunWith::Files::FilePath, Pathname
44
+ @spreadsheet_file = self.class.find_spreadsheet_in_filesystem( firstarg )
45
+ when Array
46
+ @spreadsheet_file = nil
47
+ @opts[:columns] ||= firstarg
48
+ end
49
+
134
50
  @opts[:header] = (@opts[:header] == false ? false : true) # true unless already explicitly set to false
135
51
 
136
52
  reload_spreadsheet
137
53
  end
138
54
 
139
- def reload_spreadsheet( opts = @opts )
140
- load_csv
141
- set_initial_columns( opts[:columns] )
142
- create_record_class
143
- package
144
-
145
- set_index_columns( opts[:index] )
146
- reindex
147
- end
55
+
148
56
 
149
- def set_index_columns( index_columns )
150
- @index_columns = case index_columns
151
- when NilClass
152
- []
153
- when Symbol
154
- [ index_columns ]
155
- when Array
156
- index_columns
57
+ def to_csv( opts = {} )
58
+ self.records.inject( self.columns.to_csv( opts ) ) do |output, record|
59
+ output << record.to_csv( opts )
157
60
  end
158
61
  end
159
-
62
+
160
63
  def [] offset
161
64
  @records[offset]
162
65
  end
163
-
164
- # Fetches an indexed record based on the column indexed and the keying object.
165
- # If key is an array of keying objects, returns an array of records in the
166
- # same order that the keying objects appear.
167
- # Index column should yield a different, unique value for each record.
168
- def fetch( index_col, key )
169
- if key.is_a?(Array)
170
- key.map{ |key| @indexes[index_col][key] }
171
- else
172
- @indexes[index_col][key]
173
- end
174
- end
175
-
176
- # function should take an object, and return either true or false
177
- # returns an array of objects that respond true when put through the
178
- # meat grinder
179
- def filter( &block )
180
- rval = []
181
- @records.each do |record|
182
- rval << record if ( yield record )
183
- end
184
-
185
- rval
186
- end
187
66
 
188
- # removes rows which fail the given test from the spreadsheet.
189
- def filter!( &block )
190
- @records = self.filter( &block )
191
- reindex
192
- @records
67
+ def records
68
+ @records ||= []
193
69
  end
194
70
 
195
- def column col
196
- @records.map(&col)
71
+
72
+ # give a copy of the current spreadsheet, but with no records
73
+ def blanked()
74
+ sheet = self.class.new
75
+ sheet.columns = @columns.clone
76
+ sheet.index_columns = @index_columns.clone
77
+ sheet.records = []
78
+ sheet.spreadsheet_file = nil
79
+ sheet.create_data_accessor_module
80
+ sheet.create_record_class
81
+ sheet.opts = @opts.clone
82
+ sheet.reindex
83
+
84
+ sheet
197
85
  end
198
86
 
199
- # retrieve multiple columns. Returns an array of the form
200
- # [ [record1:col1, record1:col2...], [record2:col1, record2:col2...] [...] ]
201
- def multiple_columns(*args)
202
- @records.inject([]){ |memo, record|
203
- memo << args.map{ |arg| record.send(arg) }
204
- memo
205
- }
87
+ # give a block, and get back a hash.
88
+ # The hash keys are the results of the block.
89
+ # The hash values are copies of the spreadsheets, with only the records
90
+ # which caused the block to return the key.
91
+ def split( &block )
92
+ sheets = Hash.new
93
+
94
+ for record in @records
95
+ result_key = yield record
96
+ ( sheets[result_key] ||= self.blanked() ) << record
97
+ end
98
+
99
+ sheets
100
+ # sheet_args = self.blanked
101
+ # for key, record_set in records
102
+ # sheet = self.clone
103
+ # sheet.records =
104
+ #
105
+ # records[key] = sheet
106
+ # end
107
+ #
108
+ # records
206
109
  end
207
110
 
111
+
208
112
  # if blank is defined, only the records which are non-blank in that
209
113
  # column will actually be yielded. The rest will be set to the provided
210
114
  # default
@@ -214,91 +118,6 @@ module CsvMadness
214
118
  end
215
119
  end
216
120
 
217
- def alter_column( column, blank = :undefined, &block )
218
- raise "Column does not exist: #{column}" unless @columns.include?( column )
219
-
220
- if cindex = @columns.index( column )
221
- for record in @records
222
- if record.blank?(column) && blank != :undefined
223
- record[cindex] = blank
224
- else
225
- record[cindex] = yield( record[cindex], record )
226
- end
227
- end
228
- end
229
- end
230
-
231
- # If no block given, adds an empty column
232
- def add_column( column, &block )
233
- raise "Column already exists: #{column}" if @columns.include?( column )
234
- @columns << column
235
-
236
- # add empty column to each row
237
- @records.map{ |r|
238
- r.csv_data << {column => ""}
239
- }
240
-
241
- update_data_accessor_module
242
-
243
- if block_given?
244
- alter_column( column ) do |val, record|
245
- yield val, record
246
- end
247
- end
248
- end
249
-
250
- def drop_column( column )
251
- raise "Column does not exist" unless @columns.include?( column )
252
-
253
- @columns.delete( column )
254
-
255
- key = column.to_s
256
-
257
- @records.map{ |r|
258
- r.csv_data.delete( key )
259
- }
260
-
261
- update_data_accessor_module
262
- end
263
-
264
- def rename_column( column, new_name )
265
- @columns[@columns.index(column)] = new_name
266
- rename_index_column( column, new_name ) if @index_columns.include?( column )
267
- update_data_accessor_module
268
- end
269
-
270
- def set_column_type( column, type, blank = :undefined )
271
- alter_column( column, blank, &COLUMN_TYPES[type] )
272
- end
273
-
274
-
275
- # If :reverse_merge is true, then the dest column is only overwritten for records where :dest is blank
276
- def merge_columns( source, dest, opts = {} )
277
- opts = { :drop_source => true, :reverse_merge => false, :default => "" }.merge( opts )
278
- column_must_exist( source, dest )
279
-
280
- self.records.each do |record|
281
- if opts[:reverse_merge] == false || record.blank?( dest )
282
- record[dest] = record.blank?(source) ? opts[:default] : record[source]
283
- end
284
- end
285
-
286
- self.drop_column( source ) if opts[:drop_source]
287
- end
288
-
289
- # By default, the
290
- def concat_columns( col1, col2, opts = {} )
291
- opts = {:separator => '', :out => col1}.merge( opts )
292
-
293
- column_must_exist( col1, col2 )
294
- self.add_column( opts[:out] ) unless self.columns.include?( opts[:out] )
295
-
296
- for record in self.records
297
- record[ opts[:out] ] = "#{record[col1]}#{opts[:separator]}#{record[col2]}"
298
- end
299
- end
300
-
301
- alias :concatenate :concat_columns
302
121
 
303
122
  # Note: If a block is given, the mod arg will be ignored.
304
123
  def add_record_methods( mod = nil, &block )
@@ -316,36 +135,20 @@ module CsvMadness
316
135
  end
317
136
  end
318
137
 
138
+ def length
139
+ self.records.length
140
+ end
141
+
319
142
  protected
143
+ attr_writer :columns, :index_columns, :records, :record_class, :opts
144
+
320
145
  def load_csv
146
+
321
147
  # encoding seems to solve a specific problem with a specific spreadsheet, at an unknown cost.
322
148
  @csv = CSV.new( File.read(@spreadsheet_file).force_encoding("ISO-8859-1").encode("UTF-8"),
323
149
  { write_headers: true,
324
150
  headers: ( @opts[:header] ? :first_row : false ) } )
325
151
  end
326
-
327
- def add_to_index( col, key, record )
328
- @indexes[col][key] = record
329
- end
330
-
331
- # Reindexes the record lookup tables.
332
- def reindex
333
- @indexes = {}
334
- for col in @index_columns
335
- @indexes[col] = {}
336
-
337
- for record in @records
338
- add_to_index( col, record.send(col), record )
339
- end
340
- end
341
- end
342
-
343
- # shouldn't require reindex
344
- def rename_index_column( column, new_name )
345
- @index_columns[ @index_columns.index( column ) ] = new_name
346
- @indexes[new_name] = @indexes[column]
347
- @indexes.delete(column)
348
- end
349
152
 
350
153
  # Each spreadsheet has its own anonymous record class, and each CSV row instantiates
351
154
  # a record of this class. This is where the getters and setters come from.
@@ -381,18 +184,22 @@ module CsvMadness
381
184
  # prints a warning and a comparison of the columns to the headers.
382
185
  def set_initial_columns( columns = nil )
383
186
  if columns.nil?
384
- if @opts[:header] == false #
385
- @columns = (0...csv_column_count).map{ |i| :"col#{i}" }
187
+ if @opts[:header] == false
188
+ columns = (0...csv_column_count).map{ |i| :"col#{i}" }
386
189
  else
387
- @columns = fetch_csv_headers.map{ |name| self.class.getter_name( name ) }
190
+ columns = fetch_csv_headers.map{ |name| self.class.getter_name( name ) }
388
191
  end
389
192
  else
390
- @columns = columns
391
- unless @columns.length == csv_column_count
392
- puts "Warning <#{@spreadsheet_file}>: columns array does not match the number of columns in the spreadsheet."
193
+ unless !@csv || columns.length == csv_column_count
194
+ $stderr.puts "Warning <#{@spreadsheet_file}>: columns array does not match the number of columns in the spreadsheet."
195
+ @columns = columns
393
196
  compare_columns_to_headers
394
197
  end
395
198
  end
199
+
200
+ raise_on_forbidden_column_names( columns )
201
+
202
+ @columns = columns
396
203
  end
397
204
 
398
205
  # Printout so the user can see which CSV columns are being matched to which
@@ -400,9 +207,10 @@ module CsvMadness
400
207
  # column list.
401
208
  def compare_columns_to_headers
402
209
  headers = fetch_csv_headers
403
-
404
- for i in 0...([@columns, headers].map(&:length).max)
405
- puts "\t#{i}: #{@columns[i]} ==> #{headers[i]}"
210
+ max_index = ([@columns, headers].map(&:length).max)
211
+
212
+ for i in 0 ... max_index
213
+ $stdout.puts "\t#{i}: #{@columns[i].inspect} ==> #{headers[i].inspect}"
406
214
  end
407
215
  end
408
216
 
@@ -410,8 +218,8 @@ module CsvMadness
410
218
  # Create objects that respond to the recipe-named methods
411
219
  def package
412
220
  @records = []
413
- @csv.each do |row|
414
- @records << @record_class.new( row )
221
+ (@csv || []).each do |row|
222
+ @records << @record_class.new( row, self.column_accessors_map )
415
223
  end
416
224
  end
417
225
 
@@ -420,6 +228,8 @@ module CsvMadness
420
228
  fetch_csv_headers.length
421
229
  end
422
230
 
231
+ # returns a mapping based off the current ordered list of columns.
232
+ # A hash where the first column
423
233
  def columns_to_mapping
424
234
  @columns.each_with_index.inject({}){ |memo, item|
425
235
  memo[item.first] = item.last
@@ -434,13 +244,12 @@ module CsvMadness
434
244
  end
435
245
 
436
246
  def update_data_accessor_module
247
+ debugger
437
248
  @module.remap_accessors( columns_to_mapping )
438
249
  end
439
-
440
- def column_must_exist( *cols )
441
- for col in cols
442
- raise ArgumentError.new( "#{caller[0]}: column :#{col} does not exist.") unless self.columns.include?(col)
443
- end
250
+
251
+ def column_accessors_map
252
+ @module.column_accessors_map
444
253
  end
445
254
  end
446
255
  end
@@ -0,0 +1,6 @@
1
+ module CsvMadness
2
+ module SheetMethods
3
+ module Base
4
+ end
5
+ end
6
+ end