csv-mapper 0.0.4 → 0.5.0

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,55 @@
1
+ module CsvMapper
2
+ # A CsvMapper::AttributeMap contains the instructions to parse a value from a CSV row and to know the
3
+ # name of the attribute it is targeting.
4
+ class AttributeMap
5
+ attr_reader :name, :index
6
+
7
+ # Creates a new instance using the provided attribute +name+, CSV row +index+, and evaluation +map_context+
8
+ def initialize(name, index, map_context)
9
+ @name, @index, @map_context = name, index, map_context
10
+ end
11
+
12
+ # Set the index that this map is targeting.
13
+ #
14
+ # Returns this AttributeMap for chainability
15
+ def at(index)
16
+ @index = index
17
+ self
18
+ end
19
+
20
+ # Provide a lambda or the symbol name of a method on this map's evaluation context to be used when parsing
21
+ # the value from a CSV row.
22
+ # Both the lambda or the method provided should accept a single +row+ parameter
23
+ #
24
+ # Returns this AttributeMap for chainability
25
+ def map(transform=nil, &block_transform)
26
+ @transformer = block_transform || transform
27
+ self
28
+ end
29
+
30
+ # Given a CSV row, return the value at this AttributeMap's index using any provided map transforms (see map)
31
+ def parse(csv_row)
32
+ @transformer ? parse_transform(csv_row) : raw_value(csv_row)
33
+ end
34
+
35
+ # Access the raw value of the CSV row without any map transforms applied.
36
+ def raw_value(csv_row)
37
+ csv_row[self.index]
38
+ end
39
+
40
+ private
41
+
42
+ def parse_transform(csv_row)
43
+ if @transformer.is_a? Symbol
44
+ transform_name = @transformer
45
+ @transformer = lambda{|row, index| @map_context.send(transform_name, row, index) }
46
+ end
47
+
48
+ if @transformer.arity == 1
49
+ @transformer.call(csv_row)
50
+ else
51
+ @transformer.call(csv_row, @index)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,179 @@
1
+ require 'csv-mapper/attribute_map'
2
+
3
+ module CsvMapper
4
+ # CsvMapper::RowMap provides a simple, DSL-like interface for constructing mappings.
5
+ # A CsvMapper::RowMap provides the main functionality of the library. It will mostly be used indirectly through the CsvMapper API,
6
+ # but may be useful to use directly for the dynamic CSV mappings.
7
+ class RowMap
8
+ #Start with a 'blank slate'
9
+ instance_methods.each { |m| undef_method m unless m =~ /^__||instance_eval/ }
10
+
11
+ Infinity = 1.0/0
12
+ attr_reader :mapped_attributes
13
+
14
+ # Create a new instance with access to an evaluation context
15
+ def initialize(context, csv_data = nil, &map_block)
16
+ @context = context
17
+ @csv_data = csv_data
18
+ @before_filters = []
19
+ @after_filters = []
20
+ @parser_options = {}
21
+ @start_at_row = 0
22
+ @stop_at_row = Infinity
23
+ @delimited_by = FasterCSV::DEFAULT_OPTIONS[:col_sep]
24
+ @mapped_attributes = []
25
+
26
+ self.instance_eval(&map_block) if block_given?
27
+ end
28
+
29
+ # Each row of a CSV is parsed and mapped to a new instance of a Ruby class; Struct by default.
30
+ # Use this method to change the what class each row is mapped to.
31
+ # The given class must respond to a parameter-less #new and all attribute mappings defined.
32
+ # Providing a hash of defaults will ensure that each resulting object will have the providing name and attribute values
33
+ # unless overridden by a mapping
34
+ def map_to(klass, defaults={})
35
+ @map_to_klass = klass
36
+
37
+ defaults.each do |name, value|
38
+ self.add_attribute(name, -99).map lambda{|row, index| value}
39
+ end
40
+ end
41
+
42
+ # Allow us to read the first line of a csv file to automatically generate the attribute names.
43
+ # Spaces are replaced with underscores and non-word characters are removed.
44
+ #
45
+ # Keep in mind that there is potential for overlap in using this (i.e. you have a field named
46
+ # files+ and one named files- and they both get named 'files').
47
+ #
48
+ # You can specify aliases to rename fields to prevent conflicts and/or improve readability and compatibility.
49
+ #
50
+ # i.e. read_attributes_from_file('files+' => 'files_plus', 'files-' => 'files_minus)
51
+ def read_attributes_from_file aliases = {}
52
+ attributes = FasterCSV.new(@csv_data, @parser_options).readline
53
+ @start_at_row = [ @start_at_row, 1 ].max
54
+ @csv_data.rewind
55
+ attributes.each_with_index do |name, index|
56
+ name.strip!
57
+ use_name = aliases[name] || name.gsub(/\s+/, '_').gsub(/[\W]+/, '').downcase
58
+ add_attribute use_name, index
59
+ end
60
+ end
61
+
62
+ # Specify a hash of FasterCSV options to be used for CSV parsing
63
+ #
64
+ # Can be anything FasterCSV::new()[http://fastercsv.rubyforge.org/classes/FasterCSV.html#M000018] accepts
65
+ def parser_options(opts=nil)
66
+ @parser_options = opts if opts
67
+ @parser_options.merge :col_sep => @delimited_by
68
+ end
69
+
70
+ # Convenience method to 'move' the cursor skipping the current index.
71
+ def _SKIP_
72
+ self.move_cursor
73
+ end
74
+
75
+ # Specify the CSV column delimiter. Defaults to comma.
76
+ def delimited_by(delimiter=nil)
77
+ @delimited_by = delimiter if delimiter
78
+ @delimited_by
79
+ end
80
+
81
+ # Declare what row to begin parsing the CSV.
82
+ # This is useful for skipping headers and such.
83
+ def start_at_row(row_number=nil)
84
+ @start_at_row = row_number if row_number
85
+ @start_at_row
86
+ end
87
+
88
+ # Declare the last row to be parsed in a CSV.
89
+ def stop_at_row(row_number=nil)
90
+ @stop_at_row = row_number if row_number
91
+ @stop_at_row
92
+ end
93
+
94
+ # Declare method name symbols and/or lambdas to be executed before each row.
95
+ # Each method or lambda must accept to parameters: +csv_row+, +target_object+
96
+ # Methods names should refer to methods available within the RowMap's provided context
97
+ def before_row(*befores)
98
+ self.add_filters(@before_filters, *befores)
99
+ end
100
+
101
+ # Declare method name symbols and/or lambdas to be executed before each row.
102
+ # Each method or lambda must accept to parameters: +csv_row+, +target_object+
103
+ # Methods names should refer to methods available within the RowMap's provided context
104
+ def after_row(*afters)
105
+ self.add_filters(@after_filters, *afters)
106
+ end
107
+
108
+ # Add a new attribute to this map. Mostly used internally, but is useful for dynamic map creation.
109
+ # returns the newly created CsvMapper::AttributeMap
110
+ def add_attribute(name, index=nil)
111
+ attr_mapping = CsvMapper::AttributeMap.new(name.to_sym, index, @context)
112
+ self.mapped_attributes << attr_mapping
113
+ attr_mapping
114
+ end
115
+
116
+ # The current cursor location
117
+ def cursor # :nodoc:
118
+ @cursor ||= 0
119
+ end
120
+
121
+ # Move the cursor relative to it's current position
122
+ def move_cursor(positions=1) # :nodoc:
123
+ self.cursor += positions
124
+ end
125
+
126
+ # Given a CSV row return an instance of an object defined by this mapping
127
+ def parse(csv_row)
128
+ target = self.map_to_class.new
129
+ @before_filters.each {|filter| filter.call(csv_row, target) }
130
+
131
+ self.mapped_attributes.each do |attr_map|
132
+ target.send("#{attr_map.name}=", attr_map.parse(csv_row))
133
+ end
134
+
135
+ @after_filters.each {|filter| filter.call(csv_row, target) }
136
+
137
+ return target
138
+ end
139
+
140
+ protected # :nodoc:
141
+
142
+ # The Hacktastic "magic"
143
+ # Used to dynamically create CsvMapper::AttributeMaps based on unknown method calls that
144
+ # should represent the names of mapped attributes.
145
+ #
146
+ # An optional first argument is used to move this maps cursor position and as the index of the
147
+ # new AttributeMap
148
+ def method_missing(name, *args) # :nodoc:
149
+
150
+ if index = args[0]
151
+ self.move_cursor(index - self.cursor)
152
+ else
153
+ index = self.cursor
154
+ self.move_cursor
155
+ end
156
+
157
+ add_attribute(name, index)
158
+ end
159
+
160
+ def add_filters(to_hook, *filters) # :nodoc:
161
+ (to_hook << filters.collect do |filter|
162
+ filter.is_a?(Symbol) ? lambda{|row, target| @context.send(filter, row, target)} : filter
163
+ end).flatten!
164
+ end
165
+
166
+ def map_to_class # :nodoc:
167
+ unless @map_to_klass
168
+ attrs = mapped_attributes.collect {|attr_map| attr_map.name}
169
+ @map_to_klass = Struct.new(nil, *attrs)
170
+ end
171
+
172
+ @map_to_klass
173
+ end
174
+
175
+ def cursor=(value) # :nodoc:
176
+ @cursor=value
177
+ end
178
+ end
179
+ end
@@ -1,9 +1,9 @@
1
- require File.dirname(__FILE__) + '/spec_helper.rb'
1
+ require File.dirname(__FILE__) + '/../spec_helper.rb'
2
2
 
3
3
  describe CsvMapper::AttributeMap do
4
4
 
5
5
  class TestContext
6
- def transform_it(row)
6
+ def transform_it(row, index)
7
7
  :transform_it_success
8
8
  end
9
9
  end
@@ -34,11 +34,26 @@ describe CsvMapper::AttributeMap do
34
34
  @row_attr.parse(@csv_row).should == @csv_row[1]
35
35
  end
36
36
 
37
- it "should parse values using mapped transformer" do
37
+ it "should parse values using a mapped lambda transformers" do
38
+ @row_attr.map( lambda{|row, index| :success } )
39
+ @row_attr.parse(@csv_row).should == :success
40
+ end
41
+
42
+ it "should parse values using a mapped lambda transformer that only accepts the row" do
38
43
  @row_attr.map( lambda{|row| :success } )
39
44
  @row_attr.parse(@csv_row).should == :success
40
45
  end
41
46
 
47
+ it "should parse values using a mapped block transformers" do
48
+ @row_attr.map {|row, index| :success }
49
+ @row_attr.parse(@csv_row).should == :success
50
+ end
51
+
52
+ it "should parse values using a mapped block transformer that only accepts the row" do
53
+ @row_attr.map {|row, index| :success }
54
+ @row_attr.parse(@csv_row).should == :success
55
+ end
56
+
42
57
  it "should parse values using a named method on the context" do
43
58
  @row_attr.map(:transform_it).parse(@csv_row).should == :transform_it_success
44
59
  end
@@ -47,4 +62,4 @@ describe CsvMapper::AttributeMap do
47
62
  @row_attr.raw_value(@csv_row).should be(@csv_row[@row_attr.index])
48
63
  end
49
64
 
50
- end
65
+ end
@@ -1,4 +1,4 @@
1
- require File.dirname(__FILE__) + '/spec_helper.rb'
1
+ require File.dirname(__FILE__) + '/../spec_helper.rb'
2
2
 
3
3
  describe CsvMapper::RowMap do
4
4
 
@@ -7,7 +7,7 @@ describe CsvMapper::RowMap do
7
7
  end
8
8
 
9
9
  class TestMapContext
10
- def transform(row)
10
+ def transform(row, index)
11
11
  :transform_success
12
12
  end
13
13
 
@@ -25,8 +25,8 @@ describe CsvMapper::RowMap do
25
25
  @row_map.parse(@csv_row).should_not be_nil
26
26
  end
27
27
 
28
- it "should map to a OpenStruct by default" do
29
- @row_map.parse(@csv_row).should be_instance_of(OpenStruct)
28
+ it "should map to a Struct by default" do
29
+ @row_map.parse(@csv_row).should be_kind_of(Struct)
30
30
  end
31
31
 
32
32
  it "should parse a CSV row returning the mapped result" do
@@ -78,9 +78,11 @@ describe CsvMapper::RowMap do
78
78
  end
79
79
 
80
80
  it "should allow after row processing" do
81
- @row_map.after_row lambda{|row, target| target.bam = :woot}
81
+ filter_var = nil
82
+ @row_map.after_row lambda{|row, target| filter_var = :woot}
82
83
 
83
- @row_map.parse(@csv_row).bam.should == :woot
84
+ @row_map.parse(@csv_row)
85
+ filter_var.should == :woot
84
86
  end
85
87
 
86
88
  it "should have a moveable cursor" do
@@ -124,7 +126,7 @@ describe CsvMapper::RowMap do
124
126
  @row_map.cursor.should be(1)
125
127
  end
126
128
 
127
- it "should share it context with its mappings" do
129
+ it "should share its context with its mappings" do
128
130
  @row_map.first_name.map(:transform)
129
131
  @row_map.parse(@csv_row).first_name.should == :transform_success
130
132
  end
@@ -1,76 +1,173 @@
1
1
  require File.dirname(__FILE__) + '/spec_helper.rb'
2
2
 
3
- include CsvMapper
4
-
5
3
  describe CsvMapper do
6
-
7
- before(:each) do
8
- @mapped_klass = Class.new { include CsvMapper }
9
- @mapped = @mapped_klass.new
10
- end
4
+ describe "included" do
5
+ before(:each) do
6
+ @mapped_klass = Class.new do
7
+ include CsvMapper
8
+ def upcase_name(row, index)
9
+ row[index].upcase
10
+ end
11
+ end
12
+ @mapped = @mapped_klass.new
13
+ end
11
14
 
12
- it "should allow the creation of CSV mappings" do
13
- mapping = @mapped.map_csv do
14
- start_at_row 2
15
+ it "should allow the creation of CSV mappings" do
16
+ mapping = @mapped.map_csv do
17
+ start_at_row 2
18
+ end
19
+
20
+ mapping.should be_instance_of(CsvMapper::RowMap)
21
+ mapping.start_at_row.should == 2
15
22
  end
23
+
24
+ it "should import a CSV IO" do
25
+ io = 'foo,bar,00,01'
26
+ results = @mapped.import(io, :type => :io) do
27
+ first
28
+ second
29
+ end
16
30
 
17
- mapping.should be_instance_of(CsvMapper::RowMap)
18
- mapping.start_at_row.should == 2
19
- end
31
+ results.should be_kind_of(Enumerable)
32
+ results.should have(1).things
33
+ results[0].first.should == 'foo'
34
+ results[0].second.should == 'bar'
35
+ end
20
36
 
21
- it "should import a CSV IO" do
22
- io = 'foo,bar,00,01'
23
- results = @mapped.import(io, :type => :io) do
24
- first
25
- second
37
+ it "should import a CSV File IO" do
38
+ results = @mapped.import(File.dirname(__FILE__) + '/test.csv') do
39
+ start_at_row 1
40
+ [first_name, last_name, age]
41
+ end
42
+
43
+ results.size.should == 3
44
+ end
45
+
46
+ it "should stop importing at a specified row" do
47
+ results = @mapped.import(File.dirname(__FILE__) + '/test.csv') do
48
+ start_at_row 1
49
+ stop_at_row 2
50
+ [first_name, last_name, age]
51
+ end
52
+
53
+ results.size.should == 2
54
+ end
55
+
56
+ it "should be able to read attributes from a csv file" do
57
+ results = @mapped.import(File.dirname(__FILE__) + '/test.csv') do
58
+ # we'll alias age here just as an example
59
+ read_attributes_from_file('Age' => 'number_of_years_old')
60
+ end
61
+ results[1].first_name.should == 'Jane'
62
+ results[1].last_name.should == 'Doe'
63
+ results[1].number_of_years_old.should == '26'
64
+ end
65
+
66
+ it "should import non-comma delimited files" do
67
+ piped_io = 'foo|bar|00|01'
68
+
69
+ results = @mapped.import(piped_io, :type => :io) do
70
+ delimited_by '|'
71
+ [first, second]
72
+ end
73
+
74
+ results.should have(1).things
75
+ results[0].first.should == 'foo'
76
+ results[0].second.should == 'bar'
26
77
  end
27
78
 
28
- results.should be_kind_of(Enumerable)
29
- results.should have(1).things
30
- results[0].first.should == 'foo'
31
- results[0].second.should == 'bar'
79
+ it "should allow named tranformation mappings" do
80
+ def upcase_name(row)
81
+ row[0].upcase
82
+ end
83
+
84
+ results = @mapped.import(File.dirname(__FILE__) + '/test.csv') do
85
+ start_at_row 1
86
+
87
+ first_name.map :upcase_name
88
+ end
89
+
90
+ results[0].first_name.should == 'JOHN'
91
+ end
32
92
  end
33
93
 
34
- it "should import a CSV File IO" do
35
- results = import(File.dirname(__FILE__) + '/test.csv') do
36
- start_at_row 1
37
- [first_name, last_name, age]
38
- end
94
+ describe "extended" do
95
+ it "should allow the creation of CSV mappings" do
96
+ mapping = CsvMapper.map_csv do
97
+ start_at_row 2
98
+ end
39
99
 
40
- results.size.should == 3
41
- end
100
+ mapping.should be_instance_of(CsvMapper::RowMap)
101
+ mapping.start_at_row.should == 2
102
+ end
42
103
 
43
- it "should stop importing at a specified row" do
44
- results = import(File.dirname(__FILE__) + '/test.csv') do
45
- start_at_row 1
46
- stop_at_row 2
47
- [first_name, last_name, age]
104
+ it "should import a CSV IO" do
105
+ io = 'foo,bar,00,01'
106
+ results = CsvMapper.import(io, :type => :io) do
107
+ first
108
+ second
109
+ end
110
+
111
+ results.should be_kind_of(Enumerable)
112
+ results.should have(1).things
113
+ results[0].first.should == 'foo'
114
+ results[0].second.should == 'bar'
48
115
  end
116
+
117
+ it "should import a CSV File IO" do
118
+ results = CsvMapper.import(File.dirname(__FILE__) + '/test.csv') do
119
+ start_at_row 1
120
+ [first_name, last_name, age]
121
+ end
122
+
123
+ results.size.should == 3
124
+ end
125
+
126
+ it "should stop importing at a specified row" do
127
+ results = CsvMapper.import(File.dirname(__FILE__) + '/test.csv') do
128
+ start_at_row 1
129
+ stop_at_row 2
130
+ [first_name, last_name, age]
131
+ end
49
132
 
50
- results.size.should == 2
51
- end
133
+ results.size.should == 2
134
+ end
52
135
 
53
- it "should be able to read attributes from a csv file" do
54
- results = import(File.dirname(__FILE__) + '/test.csv') do
55
- # we'll alias age here just as an example
56
- read_attributes_from_file('Age' => 'number_of_years_old')
136
+ it "should be able to read attributes from a csv file" do
137
+ results = CsvMapper.import(File.dirname(__FILE__) + '/test.csv') do
138
+ # we'll alias age here just as an example
139
+ read_attributes_from_file('Age' => 'number_of_years_old')
140
+ end
141
+ results[1].first_name.should == 'Jane'
142
+ results[1].last_name.should == 'Doe'
143
+ results[1].number_of_years_old.should == '26'
57
144
  end
58
- results[1].first_name.should == 'Jane'
59
- results[1].last_name.should == 'Doe'
60
- results[1].number_of_years_old.should == '26'
61
- end
62
145
 
63
- it "should import non-comma delimited files" do
64
- piped_io = 'foo|bar|00|01'
146
+ it "should import non-comma delimited files" do
147
+ piped_io = 'foo|bar|00|01'
65
148
 
66
- results = import(piped_io, :type => :io) do
67
- delimited_by '|'
68
- [first, second]
149
+ results = CsvMapper.import(piped_io, :type => :io) do
150
+ delimited_by '|'
151
+ [first, second]
152
+ end
153
+
154
+ results.should have(1).things
155
+ results[0].first.should == 'foo'
156
+ results[0].second.should == 'bar'
69
157
  end
70
158
 
71
- results.should have(1).things
72
- results[0].first.should == 'foo'
73
- results[0].second.should == 'bar'
159
+ it "should not allow tranformation mappings" do
160
+ def upcase_name(row)
161
+ row[0].upcase
162
+ end
163
+
164
+ (lambda do
165
+ results = CsvMapper.import(File.dirname(__FILE__) + '/test.csv') do
166
+ start_at_row 1
167
+
168
+ first_name.map :upcase_name
169
+ end
170
+ end).should raise_error(Exception)
171
+ end
74
172
  end
75
-
76
173
  end