csv-mapper 0.0.4 → 0.5.0

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