jsanders-ruport 1.7.1

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.
Files changed (76) hide show
  1. data/AUTHORS +48 -0
  2. data/LICENSE +59 -0
  3. data/README +114 -0
  4. data/Rakefile +93 -0
  5. data/examples/RWEmerson.jpg +0 -0
  6. data/examples/anon.rb +43 -0
  7. data/examples/btree/commaleon/commaleon.rb +263 -0
  8. data/examples/btree/commaleon/sample_data/ticket_count.csv +124 -0
  9. data/examples/btree/commaleon/sample_data/ticket_count2.csv +119 -0
  10. data/examples/centered_pdf_text_box.rb +83 -0
  11. data/examples/data/tattle.dump +82 -0
  12. data/examples/example.csv +3 -0
  13. data/examples/line_plotter.rb +61 -0
  14. data/examples/pdf_report_with_common_base.rb +72 -0
  15. data/examples/png_embed.rb +54 -0
  16. data/examples/roadmap.png +0 -0
  17. data/examples/row_renderer.rb +39 -0
  18. data/examples/simple_pdf_lines.rb +25 -0
  19. data/examples/simple_templating_example.rb +34 -0
  20. data/examples/tattle_ruby_version.rb +39 -0
  21. data/examples/tattle_rubygems_version.rb +37 -0
  22. data/examples/trac_ticket_status.rb +59 -0
  23. data/lib/ruport.rb +127 -0
  24. data/lib/ruport/controller.rb +616 -0
  25. data/lib/ruport/controller/grouping.rb +71 -0
  26. data/lib/ruport/controller/table.rb +54 -0
  27. data/lib/ruport/data.rb +4 -0
  28. data/lib/ruport/data/feeder.rb +111 -0
  29. data/lib/ruport/data/grouping.rb +399 -0
  30. data/lib/ruport/data/record.rb +297 -0
  31. data/lib/ruport/data/table.rb +950 -0
  32. data/lib/ruport/extensions.rb +4 -0
  33. data/lib/ruport/formatter.rb +254 -0
  34. data/lib/ruport/formatter/csv.rb +149 -0
  35. data/lib/ruport/formatter/html.rb +161 -0
  36. data/lib/ruport/formatter/pdf.rb +591 -0
  37. data/lib/ruport/formatter/template.rb +187 -0
  38. data/lib/ruport/formatter/text.rb +231 -0
  39. data/lib/uport.rb +1 -0
  40. data/test/controller_test.rb +743 -0
  41. data/test/csv_formatter_test.rb +164 -0
  42. data/test/data_feeder_test.rb +88 -0
  43. data/test/grouping_test.rb +410 -0
  44. data/test/helpers.rb +11 -0
  45. data/test/html_formatter_test.rb +201 -0
  46. data/test/pdf_formatter_test.rb +354 -0
  47. data/test/record_test.rb +332 -0
  48. data/test/samples/addressbook.csv +6 -0
  49. data/test/samples/data.csv +3 -0
  50. data/test/samples/data.tsv +3 -0
  51. data/test/samples/dates.csv +1409 -0
  52. data/test/samples/erb_test.sql +1 -0
  53. data/test/samples/query_test.sql +1 -0
  54. data/test/samples/ruport_test.sql +8 -0
  55. data/test/samples/test.sql +2 -0
  56. data/test/samples/test.yaml +3 -0
  57. data/test/samples/ticket_count.csv +124 -0
  58. data/test/table_pivot_test.rb +134 -0
  59. data/test/table_test.rb +838 -0
  60. data/test/template_test.rb +48 -0
  61. data/test/text_formatter_test.rb +258 -0
  62. data/util/bench/data/record/bench_as_vs_to.rb +18 -0
  63. data/util/bench/data/record/bench_constructor.rb +46 -0
  64. data/util/bench/data/record/bench_indexing.rb +65 -0
  65. data/util/bench/data/record/bench_reorder.rb +35 -0
  66. data/util/bench/data/record/bench_to_a.rb +19 -0
  67. data/util/bench/data/table/bench_column_manip.rb +103 -0
  68. data/util/bench/data/table/bench_dup.rb +24 -0
  69. data/util/bench/data/table/bench_init.rb +67 -0
  70. data/util/bench/data/table/bench_manip.rb +125 -0
  71. data/util/bench/formatter/bench_csv.rb +14 -0
  72. data/util/bench/formatter/bench_html.rb +14 -0
  73. data/util/bench/formatter/bench_pdf.rb +14 -0
  74. data/util/bench/formatter/bench_text.rb +14 -0
  75. data/util/bench/samples/tattle.csv +1237 -0
  76. metadata +176 -0
@@ -0,0 +1,297 @@
1
+ # Ruport : Extensible Reporting System
2
+ #
3
+ # data/record.rb provides a record data structure for Ruport.
4
+ #
5
+ # Created by Gregory Brown / Dudley Flanders, 2006
6
+ # Copyright (C) 2006 Gregory Brown / Dudley Flanders, All Rights Reserved.
7
+ #
8
+ # This is free software distributed under the same terms as Ruby 1.8
9
+ # See LICENSE and COPYING for details.
10
+ #
11
+ module Ruport::Data
12
+
13
+ # === Overview
14
+ #
15
+ # Data::Records are the work-horse of Ruport's data model. These can behave
16
+ # as Array-like, Hash-like, or Struct-like objects. They are used as the
17
+ # base element for Data::Table
18
+ #
19
+ class Record
20
+
21
+ if RUBY_VERSION < "1.9"
22
+ private :id
23
+ end
24
+
25
+ include Enumerable
26
+
27
+ # Creates a new Record object. If the <tt>:attributes</tt>
28
+ # keyword is specified, Hash-like and Struct-like
29
+ # access will be enabled. Otherwise, Record elements may be
30
+ # accessed ordinally, like an Array.
31
+ #
32
+ # A Record can accept either a Hash or an Array as its <tt>data</tt>.
33
+ #
34
+ # Examples:
35
+ # a = Record.new [1,2,3]
36
+ # a[1] #=> 2
37
+ #
38
+ # b = Record.new [1,2,3], :attributes => %w[a b c]
39
+ # b[1] #=> 2
40
+ # b['a'] #=> 1
41
+ # b.c #=> 3
42
+ #
43
+ # c = Record.new {"a" => 1, "c" => 3, "b" => 2}, :attributes => %w[a b c]
44
+ # c[1] #=> 2
45
+ # c['a'] #=> 1
46
+ # c.c #=> 3
47
+ #
48
+ # d = Record.new { "a" => 1, "c" => 3, "b" => 2 }
49
+ # d[1] #=> ? (without attributes, you cannot rely on order)
50
+ # d['a'] #=> 1
51
+ # d.c #=> 3
52
+ #
53
+ def initialize(data,options={})
54
+ data = data.dup
55
+ case(data)
56
+ when Array
57
+ @attributes = options[:attributes] || (0...data.length).to_a
58
+ @data = @attributes.inject({}) { |h,a| h.merge(a => data.shift) }
59
+ when Hash
60
+ @data = data.dup
61
+ @attributes = options[:attributes] || data.keys
62
+ end
63
+ end
64
+
65
+ ##############
66
+ # Delegators #
67
+ ##############
68
+
69
+ # Returns a copy of the <tt>attributes</tt> from this Record.
70
+ #
71
+ # Example:
72
+ #
73
+ # a = Data::Record.new([1,2],:attributes => %w[a b])
74
+ # a.attributes #=> ["a","b"]
75
+ #
76
+ def attributes
77
+ @attributes.dup
78
+ end
79
+
80
+ # Sets the <tt>attribute</tt> list for this Record.
81
+ # (Dangerous when used within Table objects!)
82
+ attr_writer :attributes
83
+
84
+ # The data for the record
85
+ attr_reader :data
86
+
87
+ # The size of the record (the number of items in the record's data).
88
+ def size; @data.size; end
89
+ alias_method :length, :size
90
+
91
+ ##################
92
+ # Access Methods #
93
+ ##################
94
+
95
+ # Allows either Array or Hash-like indexing.
96
+ #
97
+ # Examples:
98
+ #
99
+ # my_record[1]
100
+ # my_record["foo"]
101
+ #
102
+ def [](index)
103
+ case(index)
104
+ when Integer
105
+ @data[@attributes[index]]
106
+ else
107
+ @data[index]
108
+ end
109
+ end
110
+
111
+ # Allows setting a <tt>value</tt> at an <tt>index</tt>.
112
+ #
113
+ # Examples:
114
+ #
115
+ # my_record[1] = "foo"
116
+ # my_record["bar"] = "baz"
117
+ #
118
+ def []=(index,value)
119
+ case(index)
120
+ when Integer
121
+ @data[@attributes[index]] = value
122
+ else
123
+ @data[index] = value
124
+ @attributes << index unless @attributes.include? index
125
+ end
126
+ end
127
+
128
+ # Indifferent access to attributes.
129
+ #
130
+ # Examples:
131
+ #
132
+ # record.get(:foo) # looks for an attribute "foo" or :foo,
133
+ # or calls the method <tt>foo</tt>
134
+ #
135
+ # record.get("foo") # looks for an attribute "foo" or :foo
136
+ #
137
+ # record.get(0) # Gets the first element
138
+ #
139
+ def get(name)
140
+ case name
141
+ when String,Symbol
142
+ self[name] || send(name)
143
+ when Fixnum
144
+ self[name]
145
+ else
146
+ raise ArgumentError, "Whatchu Talkin' Bout, Willis?"
147
+ end
148
+ end
149
+
150
+ ################
151
+ # Conversions #
152
+ ################
153
+
154
+ # Converts a Record into an Array.
155
+ #
156
+ # Example:
157
+ #
158
+ # a = Data::Record.new([1,2],:attributes => %w[a b])
159
+ # a.to_a #=> [1,2]
160
+ #
161
+ def to_a
162
+ @attributes.map { |a| @data[a] }
163
+ end
164
+
165
+ # Converts a Record into a Hash.
166
+ #
167
+ # Example:
168
+ #
169
+ # a = Data::Record.new([1,2],:attributes => %w[a b])
170
+ # a.to_hash #=> {"a" => 1, "b" => 2}
171
+ #
172
+ def to_hash
173
+ @data.dup
174
+ end
175
+
176
+ ################
177
+ # Comparisons #
178
+ ################
179
+
180
+ # If <tt>attributes</tt> and <tt>to_a</tt> are equivalent, then
181
+ # <tt>==</tt> evaluates to true. Otherwise, <tt>==</tt> returns false.
182
+ #
183
+ def ==(other)
184
+ @attributes.eql?(other.attributes) &&
185
+ to_a == other.to_a
186
+ end
187
+
188
+ alias_method :eql?, :==
189
+
190
+ #############
191
+ # Iterators #
192
+ #############
193
+
194
+ # Yields each element of the Record. Does not provide attribute names.
195
+ def each
196
+ to_a.each { |e| yield(e) }
197
+ end
198
+
199
+ #################
200
+ # Manipulations #
201
+ #################
202
+
203
+ # Takes an old name and a new name and renames an attribute.
204
+ #
205
+ # The third option, update_index is for internal use.
206
+ def rename_attribute(old_name,new_name,update_index=true)
207
+ @attributes[@attributes.index(old_name)] = new_name if update_index
208
+ @data[new_name] = @data.delete(old_name)
209
+ end
210
+
211
+ # Allows you to change the order of or reduce the number of columns in a
212
+ # Record.
213
+ #
214
+ # Example:
215
+ #
216
+ # a = Data::Record.new([1,2,3,4],:attributes => %w[a b c d])
217
+ # a.reorder("a","d","b")
218
+ # a.attributes #=> ["a","d","b"]
219
+ # a.data #=> [1,4,2]
220
+ def reorder(*indices)
221
+ indices[0].kind_of?(Array) && indices.flatten!
222
+ if indices.all? { |i| i.kind_of? Integer }
223
+ raise ArgumentError unless indices.all? { |i| @attributes[i] }
224
+ self.attributes = indices.map { |i| @attributes[i] }
225
+ else
226
+ raise ArgumentError unless (indices - @attributes).empty?
227
+ self.attributes = indices
228
+ end
229
+ self
230
+ end
231
+
232
+ #######################
233
+ # Internals / Helpers #
234
+ #######################
235
+
236
+ include Ruport::Controller::Hooks
237
+ renders_as_row
238
+
239
+ def self.inherited(base) #:nodoc:
240
+ base.renders_as_row
241
+ end
242
+
243
+ # Provides a unique hash value. If a Record contains the same data and
244
+ # attributes as another Record, they will hash to the same value, even if
245
+ # they are not the same object. This is similar to the way Array works,
246
+ # but different from Hash and other objects.
247
+ #
248
+ def hash
249
+ @attributes.hash + to_a.hash
250
+ end
251
+
252
+ # Create a copy of the Record.
253
+ #
254
+ # Example:
255
+ #
256
+ # one = Record.new([1,2,3,4],:attributes => %w[a b c d])
257
+ # two = one.dup
258
+ #
259
+ def initialize_copy(from) #:nodoc:
260
+ @data = from.data.dup
261
+ @attributes = from.attributes.dup
262
+ end
263
+
264
+ # Provides accessor style methods for attribute access.
265
+ #
266
+ # Example:
267
+ #
268
+ # my_record.foo = 2
269
+ # my_record.foo #=> 2
270
+ #
271
+ # Also provides a shortcut for the <tt>as()</tt> method by converting a
272
+ # call to <tt>to_format_name</tt> into a call to <tt>as(:format_name)</tt>
273
+ #
274
+ def method_missing(id,*args,&block)
275
+ k = id.to_s.gsub(/=$/,"")
276
+ key_index = @attributes.index(k) || @attributes.index(k.to_sym)
277
+
278
+ if key_index
279
+ args[0] ? self[key_index] = args[0] : self[key_index]
280
+ else
281
+ return as($1.to_sym,*args,&block) if id.to_s =~ /^to_(.*)/
282
+ super
283
+ end
284
+ end
285
+
286
+ private
287
+
288
+ def delete(key)
289
+ @data.delete(key)
290
+ @attributes.delete(key)
291
+ end
292
+
293
+ def reindex(new_attributes)
294
+ @attributes = new_attributes
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,950 @@
1
+ # Ruport : Extensible Reporting System
2
+ #
3
+ # data/table.rb provides a table data structure for Ruport.
4
+ #
5
+ # Created by Gregory Brown / Dudley Flanders, 2006
6
+ # Copyright (C) 2006 Gregory Brown / Dudley Flanders, All Rights Reserved.
7
+ #
8
+ # This is free software distributed under the same terms as Ruby 1.8
9
+ # See LICENSE and COPYING for details.
10
+ #
11
+ module Ruport::Data
12
+
13
+ # === Overview
14
+ #
15
+ # This class is one of the core classes for building and working with data
16
+ # in Ruport. The idea is to get your data into a standard form, regardless
17
+ # of its source (a database, manual arrays, ActiveRecord, CSVs, etc.).
18
+ #
19
+ # Table is intended to be used as the data store for structured, tabular
20
+ # data.
21
+ #
22
+ # Once your data is in a Table object, it can be manipulated
23
+ # to suit your needs, then used to build a report.
24
+ #
25
+ class Table
26
+
27
+ class Pivot #:nodoc:
28
+
29
+ def initialize(table, group_col, pivot_col, summary_col, options = {})
30
+ @table = table
31
+ @group_column = group_col
32
+ @pivot_column = pivot_col
33
+ @summary_column = summary_col
34
+ @pivot_order = options[:pivot_order]
35
+ end
36
+
37
+ def convert_row_order_to_group_order(row_order_spec)
38
+ case row_order_spec
39
+ when Array
40
+ proc {|group|
41
+ row_order_spec.map {|e| group[0][e].to_s }
42
+ }
43
+ when Proc
44
+ proc {|group|
45
+ if row_order_spec.arity == 2
46
+ row_order_spec.call(group[0], group.name)
47
+ else
48
+ row_order_spec.call(group[0])
49
+ end
50
+ }
51
+ when NilClass
52
+ nil
53
+ else
54
+ proc {|group| group[0][row_order_spec].to_s }
55
+ end
56
+ end
57
+
58
+ def columns_from_pivot
59
+ ordering = convert_row_order_to_group_order(@pivot_order)
60
+ pivot_column_grouping = Grouping(@table, :by => @pivot_column)
61
+ pivot_column_grouping.each {|n,g| g.add_column(n) { n }}
62
+ pivot_column_grouping.sort_grouping_by!(ordering) if ordering
63
+ result = []
64
+ pivot_column_grouping.each {|name,_| result << name }
65
+ result
66
+ end
67
+
68
+ def group_column_entries
69
+ @table.map {|row| row[@group_column]}.uniq
70
+ end
71
+
72
+ def to_table
73
+ result = Table()
74
+ result.add_column(@group_column)
75
+ pivoted_columns = columns_from_pivot
76
+ pivoted_columns.each { |name| result.add_column(name) }
77
+ outer_grouping = Grouping(@table, :by => @group_column)
78
+ group_column_entries.each {|outer_group_name|
79
+ outer_group = outer_grouping[outer_group_name]
80
+ pivot_values = pivoted_columns.inject({}) do |hsh, e|
81
+ matching_rows = outer_group.rows_with(@pivot_column => e)
82
+ hsh[e] = matching_rows.first && matching_rows.first[@summary_column]
83
+ hsh
84
+ end
85
+ result << [outer_group_name] + pivoted_columns.map {|e|
86
+ pivot_values[e]
87
+ }
88
+ }
89
+ result
90
+ end
91
+
92
+ end
93
+
94
+ # Creates a new table with values from the specified pivot column
95
+ # transformed into columns.
96
+ #
97
+ # Required options:
98
+ # <b><tt>:group_by</tt></b>:: The name of a column whose unique
99
+ # values should become rows in the new
100
+ # table.
101
+ #
102
+ # <b><tt>:values</tt></b>:: The name of a column that should supply
103
+ # the values for the pivoted columns.
104
+ #
105
+ # Optional:
106
+ # <b><tt>:pivot_order</tt></b>:: An ordering specification for the
107
+ # pivoted columns, in terms of the source
108
+ # rows. If this is a Proc there is an
109
+ # optional second argument that receives
110
+ # the name of the pivot column, which due
111
+ # to implementation oddity currently is
112
+ # removed from the row provided in the
113
+ # first argument. This wart will likely
114
+ # be fixed in a future version.
115
+ #
116
+ # Example:
117
+ #
118
+ # Given a table <em>my_table</em>:
119
+ # +-------------------------+
120
+ # | Group | Segment | Value |
121
+ # +-------------------------+
122
+ # | A | 1 | 0 |
123
+ # | A | 2 | 1 |
124
+ # | B | 1 | 2 |
125
+ # | B | 2 | 3 |
126
+ # +-------------------------+
127
+ #
128
+ # Pivoting the table on the Segment column:
129
+ #
130
+ # my_table.pivot('Segment', :group_by => 'Group', :values => 'Value',
131
+ # :pivot_order => proc {|row, name| name})
132
+ #
133
+ # Yields a new table like this:
134
+ # +---------------+
135
+ # | Group | 1 | 2 |
136
+ # +---------------+
137
+ # | A | 0 | 1 |
138
+ # | B | 2 | 3 |
139
+ # +---------------+
140
+ #
141
+ def pivot(pivot_column, options = {})
142
+ group_column = options[:group_by] ||
143
+ raise(ArgumentError, ":group_by option required")
144
+ value_column = options[:values] ||
145
+ raise(ArgumentError, ":values option required")
146
+ Pivot.new(
147
+ self, group_column, pivot_column, value_column, options
148
+ ).to_table
149
+ end
150
+
151
+ # === Overview
152
+ #
153
+ # This module provides facilities for creating tables from csv data.
154
+ #
155
+ module FromCSV
156
+ # Loads a CSV file directly into a Table using the FasterCSV library.
157
+ #
158
+ # Example:
159
+ #
160
+ # # treat first row as column_names
161
+ # table = Table.load('mydata.csv')
162
+ #
163
+ # # do not assume the data has column_names
164
+ # table = Table.load('mydata.csv',:has_names => false)
165
+ #
166
+ # # pass in FasterCSV options, such as column separators
167
+ # table = Table.load('mydata.csv',:csv_options => { :col_sep => "\t" })
168
+ #
169
+ def load(csv_file, options={},&block)
170
+ get_table_from_csv(:foreach, csv_file, options,&block)
171
+ end
172
+
173
+ # Creates a Table from a CSV string using FasterCSV. See Table.load for
174
+ # additional examples.
175
+ #
176
+ # table = Table.parse("a,b,c\n1,2,3\n4,5,6\n")
177
+ #
178
+ def parse(string, options={},&block)
179
+ get_table_from_csv(:parse,string,options,&block)
180
+ end
181
+
182
+ private
183
+
184
+ def get_table_from_csv(msg,param,options={},&block) #:nodoc:
185
+ require "fastercsv"
186
+
187
+ options = {:has_names => true,
188
+ :csv_options => {} }.merge(options)
189
+
190
+ adjust_options_for_fcsv_headers(options)
191
+
192
+ table = self.new(options) do |feeder|
193
+ first_line = true
194
+ FasterCSV.send(msg,param,options[:csv_options]) do |row|
195
+ if first_line
196
+ adjust_for_headers(feeder.data,row,options)
197
+ first_line = false
198
+ next if options[:has_names]
199
+ end
200
+
201
+ if block
202
+ handle_csv_row_proc(feeder,row,options,block)
203
+ else
204
+ feeder << row
205
+ end
206
+ end
207
+ end
208
+
209
+ return table
210
+ end
211
+
212
+ def handle_csv_row_proc(feeder,row,options,block)
213
+ if options[:records]
214
+ rc = options[:record_class] || Record
215
+ row = rc.new(row, :attributes => feeder.data.column_names)
216
+ end
217
+
218
+ block[feeder,row]
219
+ end
220
+
221
+ def adjust_options_for_fcsv_headers(options)
222
+ options[:has_names] = false if options[:csv_options][:headers]
223
+ end
224
+
225
+ def adjust_for_headers(loaded,row,options)
226
+ if options[:has_names]
227
+ loaded.column_names = row
228
+ elsif options[:csv_options][:headers]
229
+ loaded.column_names = row.headers
230
+ end
231
+ end
232
+ end
233
+
234
+ include Enumerable
235
+ extend FromCSV
236
+
237
+ include Ruport::Controller::Hooks
238
+ renders_as_table
239
+
240
+ def self.inherited(base) #:nodoc:
241
+ base.renders_as_table
242
+ end
243
+
244
+ # Creates a new table based on the supplied options.
245
+ #
246
+ # Valid options:
247
+ # <b><tt>:data</tt></b>:: An Array of Arrays representing the
248
+ # records in this Table.
249
+ # <b><tt>:column_names</tt></b>:: An Array containing the column names
250
+ # for this Table.
251
+ # <b><tt>:filters</tt></b>:: A proc or array of procs that set up
252
+ # conditions to filter the data being
253
+ # added to the table.
254
+ # <b><tt>:transforms</tt></b>:: A proc or array of procs that perform
255
+ # transformations on the data being added
256
+ # to the table.
257
+ # <b><tt>:record_class</tt></b>:: Specify the class of the table's
258
+ # records.
259
+ #
260
+ # Example:
261
+ #
262
+ # table = Table.new :data => [[1,2,3], [3,4,5]],
263
+ # :column_names => %w[a b c]
264
+ #
265
+ def initialize(options={})
266
+ @column_names = options[:column_names] ? options[:column_names].dup : []
267
+ @record_class = options[:record_class] &&
268
+ options[:record_class].name || "Ruport::Data::Record"
269
+ @data = []
270
+
271
+ feeder = Feeder.new(self)
272
+
273
+ Array(options[:filters]).each { |f| feeder.filter(&f) }
274
+ Array(options[:transforms]).each { |t| feeder.transform(&t) }
275
+
276
+ if options[:data]
277
+ options[:data].each do |e|
278
+ if e.kind_of?(Record)
279
+ e = if @column_names.empty? or
280
+ e.attributes.all? { |a| a.kind_of?(Numeric) }
281
+ e.to_a
282
+ else
283
+ e.to_hash.values_at(*@column_names)
284
+ end
285
+ end
286
+ r = recordize(e)
287
+
288
+ feeder << r
289
+ end
290
+ end
291
+
292
+ yield(feeder) if block_given?
293
+ end
294
+
295
+ # This Table's column names
296
+ attr_reader :column_names
297
+
298
+ # This Table's data
299
+ attr_reader :data
300
+
301
+ require "forwardable"
302
+ extend Forwardable
303
+ def_delegators :@data, :each, :length, :size, :empty?, :[]
304
+
305
+ # Sets the column names for this table. <tt>new_column_names</tt> should
306
+ # be an array listing the names of the columns.
307
+ #
308
+ # Example:
309
+ #
310
+ # table = Table.new :data => [[1,2,3], [3,4,5]],
311
+ # :column_names => %w[a b c]
312
+ #
313
+ # table.column_names = %w[e f g]
314
+ #
315
+ def column_names=(new_column_names)
316
+ columns = new_column_names.zip(@column_names)
317
+ @column_names.replace(new_column_names.dup)
318
+ unless @data.empty?
319
+ each { |r|
320
+ columns.each_with_index { |x,i|
321
+ if x[1].nil?
322
+ r.rename_attribute(i,x[0])
323
+ elsif x[1] != x[0]
324
+ r.rename_attribute(x[1],x[0],false)
325
+ end
326
+ }
327
+ r.send(:reindex, @column_names)
328
+ }
329
+ end
330
+ end
331
+
332
+ # Compares this Table to another Table and returns <tt>true</tt> if
333
+ # both the <tt>data</tt> and <tt>column_names</tt> are equal.
334
+ #
335
+ # Example:
336
+ #
337
+ # one = Table.new :data => [[1,2], [3,4]],
338
+ # :column_names => %w[a b]
339
+ #
340
+ # two = Table.new :data => [[1,2], [3,4]],
341
+ # :column_names => %w[a b]
342
+ #
343
+ # one.eql?(two) #=> true
344
+ #
345
+ def eql?(other)
346
+ data.eql?(other.data) && column_names.eql?(other.column_names)
347
+ end
348
+
349
+ alias_method :==, :eql?
350
+
351
+ # Used to add extra data to the Table. <tt>row</tt> can be an Array,
352
+ # Hash or Record. It also can be anything that implements a meaningful
353
+ # to_hash or to_ary.
354
+ #
355
+ # Example:
356
+ #
357
+ # data = Table.new :data => [[1,2], [3,4]],
358
+ # :column_names => %w[a b]
359
+ # data << [8,9]
360
+ # data << { :a => 4, :b => 5}
361
+ # data << Record.new [5,6], :attributes => %w[a b]
362
+ #
363
+ def <<(row)
364
+ @data << recordize(row)
365
+ return self
366
+ end
367
+
368
+ # Returns the record class constant being used by the table.
369
+ def record_class
370
+ @record_class.split("::").inject(Class) { |c,el| c.send(:const_get,el) }
371
+ end
372
+
373
+ # Used to merge two Tables by rows.
374
+ # Raises an ArgumentError if the Tables don't have identical columns.
375
+ #
376
+ # Example:
377
+ #
378
+ # inky = Table.new :data => [[1,2], [3,4]],
379
+ # :column_names => %w[a b]
380
+ #
381
+ # blinky = Table.new :data => [[5,6]],
382
+ # :column_names => %w[a b]
383
+ #
384
+ # sue = inky + blinky
385
+ # sue.data #=> [[1,2],[3,4],[5,6]]
386
+ #
387
+ def +(other)
388
+ raise ArgumentError unless other.column_names == @column_names
389
+ self.class.new( :column_names => @column_names,
390
+ :data => @data + other.data,
391
+ :record_class => record_class )
392
+ end
393
+
394
+ # Allows you to change the order of, or reduce the number of columns in a
395
+ # Table.
396
+ #
397
+ # Example:
398
+ #
399
+ # a = Table.new :data => [[1,2,3],[4,5,6]], :column_names => %w[a b c]
400
+ # a.reorder("b","c","a")
401
+ # a.column_names #=> ["b","c","a"]
402
+ #
403
+ # a = Table.new :data => [[1,2,3],[4,5,6]], :column_names => %w[a b c]
404
+ # a.reorder(1,2,0)
405
+ # a.column_names #=> ["b","c","a"]
406
+ #
407
+ # a = Table.new :data => [[1,2,3],[4,5,6]], :column_names => %w[a b c]
408
+ # a.reorder(0,2)
409
+ # a.column_names #=> ["a","c"]
410
+ #
411
+ def reorder(*indices)
412
+ raise(ArgumentError,"Can't reorder without column names set!") if
413
+ @column_names.empty?
414
+
415
+ indices = indices[0] if indices[0].kind_of? Array
416
+
417
+ if indices.all? { |i| i.kind_of? Integer }
418
+ indices.map! { |i| @column_names[i] }
419
+ end
420
+
421
+ reduce(indices)
422
+ end
423
+
424
+ # Adds an extra column to the Table.
425
+ #
426
+ # Available Options:
427
+ # <b><tt>:default</tt></b>:: The default value to use for the column in
428
+ # existing rows. Set to nil if not specified.
429
+ #
430
+ # <b><tt>:position</tt></b>:: Inserts the column at the indicated position
431
+ # number.
432
+ #
433
+ # <b><tt>:before</tt></b>:: Inserts the new column before the column
434
+ # indicated (by name).
435
+ #
436
+ # <b><tt>:after</tt></b>:: Inserts the new column after the column
437
+ # indicated (by name).
438
+ #
439
+ # If a block is provided, it will be used to build up the column.
440
+ #
441
+ # Example:
442
+ #
443
+ # data = Table("a","b") { |t| t << [1,2] << [3,4] }
444
+ #
445
+ # # basic usage, column full of 1's
446
+ # data.add_column 'new_column', :default => 1
447
+ #
448
+ # # new empty column before new_column
449
+ # data.add_column 'new_col2', :before => 'new_column'
450
+ #
451
+ # # new column placed just after column a
452
+ # data.add_column 'new_col3', :position => 1
453
+ #
454
+ # # new column built via a block, added at the end of the table
455
+ # data.add_column("new_col4") { |r| r.a + r.b }
456
+ #
457
+ def add_column(name,options={})
458
+ if pos = options[:position]
459
+ column_names.insert(pos,name)
460
+ elsif pos = options[:after]
461
+ column_names.insert(column_names.index(pos)+1,name)
462
+ elsif pos = options[:before]
463
+ column_names.insert(column_names.index(pos),name)
464
+ else
465
+ column_names << name
466
+ end
467
+
468
+ if block_given?
469
+ each { |r| r[name] = yield(r) || options[:default] }
470
+ else
471
+ each { |r| r[name] = options[:default] }
472
+ end; self
473
+ end
474
+
475
+ # Add multiple extra columns to the Table. See <tt>add_column</tt> for
476
+ # a list of available options.
477
+ #
478
+ # Example:
479
+ #
480
+ # data = Table("a","b") { |t| t << [1,2] << [3,4] }
481
+ #
482
+ # data.add_columns ['new_column_1','new_column_2'], :default => 1
483
+ #
484
+ def add_columns(names,options={})
485
+ raise "Greg isn't smart enough to figure this out.\n"+
486
+ "Send ideas in at http://list.rubyreports.org" if block_given?
487
+ need_reverse = !!(options[:after] || options[:position])
488
+ names = names.reverse if need_reverse
489
+ names.each { |n| add_column(n,options) }
490
+ self
491
+ end
492
+
493
+ # Removes the given column from the table. May use name or position.
494
+ #
495
+ # Example:
496
+ #
497
+ # table.remove_column(0) #=> removes the first column
498
+ # table.remove_column("apple") #=> removes column named apple
499
+ #
500
+ def remove_column(col)
501
+ col = column_names[col] if col.kind_of? Fixnum
502
+ column_names.delete(col)
503
+ each { |r| r.send(:delete,col) }
504
+ end
505
+
506
+ # Removes multiple columns from the table. May use name or position
507
+ # Will autosplat arrays.
508
+ #
509
+ # Example:
510
+ # table.remove_columns('a','b','c')
511
+ # table.remove_columns([0,1])
512
+ #
513
+ def remove_columns(*cols)
514
+ cols = cols[0] if cols[0].kind_of? Array
515
+ cols.each { |col| remove_column(col) }
516
+ end
517
+
518
+ # Renames a column. Will update Record attributes as well.
519
+ #
520
+ # Example:
521
+ #
522
+ # old_values = table.map { |r| r.a }
523
+ # table.rename_column("a","zanzibar")
524
+ # new_values = table.map { |r| r.zanzibar }
525
+ # old_values == new_values #=> true
526
+ # table.column_names.include?("a") #=> false
527
+ #
528
+ def rename_column(old_name,new_name)
529
+ index = column_names.index(old_name) or return
530
+ self.column_names[index] = new_name
531
+ each { |r| r.rename_attribute(old_name,new_name,false)}
532
+ end
533
+
534
+ # Renames multiple columns. Takes either a hash of "old" => "new"
535
+ # names or two arrays of names %w[old names],%w[new names].
536
+ #
537
+ # Example:
538
+ #
539
+ # table.column_names #=> ["a", "b"]
540
+ # table.rename_columns ["a", "b"], ["c", "d"]
541
+ # table.column_names #=> ["c", "d"]
542
+ #
543
+ # table.column_names #=> ["a", "b"]
544
+ # table.rename_columns {"a" => "c", "b" => "d"}
545
+ # table.column_names #=> ["c", "d"]
546
+ #
547
+ def rename_columns(old_cols=nil,new_cols=nil)
548
+ if block_given?
549
+ if old_cols
550
+ old_cols.each { |c| rename_column(c,yield(c)) }
551
+ else
552
+ column_names.each { |c| rename_column(c,yield(c)) }
553
+ end
554
+ return
555
+ end
556
+
557
+ raise ArgumentError unless old_cols
558
+
559
+ if new_cols
560
+ raise ArgumentError,
561
+ "odd number of arguments" unless old_cols.size == new_cols.size
562
+ h = Hash[*old_cols.zip(new_cols).flatten]
563
+ else
564
+ h = old_cols
565
+ end
566
+ h.each {|old,new| rename_column(old,new) }
567
+ end
568
+
569
+ # Exchanges one column with another.
570
+ #
571
+ # Example:
572
+ #
573
+ # >> a = Table(%w[a b c]) { |t| t << [1,2,3] << [4,5,6] }
574
+ # >> puts a
575
+ # +-----------+
576
+ # | a | b | c |
577
+ # +-----------+
578
+ # | 1 | 2 | 3 |
579
+ # | 4 | 5 | 6 |
580
+ # +-----------+
581
+ # >> a.swap_column("a","c")
582
+ # >> puts a
583
+ # +-----------+
584
+ # | c | b | a |
585
+ # +-----------+
586
+ # | 3 | 2 | 1 |
587
+ # | 6 | 5 | 4 |
588
+ # +-----------+
589
+ #
590
+ def swap_column(a,b)
591
+ if [a,b].all? { |r| r.kind_of? Fixnum }
592
+ col_a,col_b = column_names[a],column_names[b]
593
+ column_names[a] = col_b
594
+ column_names[b] = col_a
595
+ else
596
+ a_ind, b_ind = [column_names.index(a), column_names.index(b)]
597
+ column_names[b_ind] = a
598
+ column_names[a_ind] = b
599
+ end
600
+ end
601
+
602
+ # Allows you to specify a new column to replace an existing column
603
+ # in your table via a block.
604
+ #
605
+ # Example:
606
+ #
607
+ # >> a = Table(%w[a b c]) { |t| t << [1,2,3] << [4,5,6] }
608
+ # >> a.replace_column("c","c2") { |r| r.c * 2 + r.a }
609
+ #
610
+ # >> puts a
611
+ # +------------+
612
+ # | a | b | c2 |
613
+ # +------------+
614
+ # | 1 | 2 | 7 |
615
+ # | 4 | 5 | 16 |
616
+ # +------------+
617
+ #
618
+ def replace_column(old_col,new_col=nil,&block)
619
+ if new_col
620
+ add_column(new_col,:after => old_col,&block)
621
+ remove_column(old_col)
622
+ else
623
+ each { |r| r[old_col] = yield(r) }
624
+ end
625
+ end
626
+
627
+ # Generates a sub table
628
+ #
629
+ # Examples:
630
+ #
631
+ # table = [[1,2,3,4],[5,6,7,8],[9,10,11,12]].to_table(%w[a b c d])
632
+ #
633
+ # Using column_names and a range:
634
+ #
635
+ # sub_table = table.sub_table(%w[a b],1..-1)
636
+ # sub_table == [[5,6],[9,10]].to_table(%w[a b]) #=> true
637
+ #
638
+ # Using just column_names:
639
+ #
640
+ # sub_table = table.sub_table(%w[a d])
641
+ # sub_table == [[1,4],[5,8],[9,12]].to_table(%w[a d]) #=> true
642
+ #
643
+ # Using column_names and a block:
644
+ #
645
+ # sub_table = table.sub_table(%w[d b]) { |r| r.a < 6 }
646
+ # sub_table == [[4,2],[8,6]].to_table(%w[d b]) #=> true
647
+ #
648
+ # Using a range for row reduction:
649
+ # sub_table = table.sub_table(1..-1)
650
+ # sub_table == [[5,6,7,8],[9,10,11,12]].to_table(%w[a b c d]) #=> true
651
+ #
652
+ # Using just a block:
653
+ #
654
+ # sub_table = table.sub_table { |r| r.c > 10 }
655
+ # sub_table == [[9,10,11,12]].to_table(%w[a b c d]) #=> true
656
+ #
657
+ def sub_table(cor=column_names,range=nil,&block)
658
+ if range
659
+ self.class.new(:column_names => cor,:data => data[range])
660
+ elsif cor.kind_of?(Range)
661
+ self.class.new(:column_names => column_names,:data => data[cor])
662
+ elsif block
663
+ self.class.new( :column_names => cor, :data => data.select(&block))
664
+ else
665
+ self.class.new( :column_names => cor, :data => data)
666
+ end
667
+ end
668
+
669
+ # Generates a sub table in place, modifying the receiver. See documentation
670
+ # for <tt>sub_table</tt>.
671
+ #
672
+ def reduce(columns=column_names,range=nil,&block)
673
+ t = sub_table(columns,range,&block)
674
+ @data = t.data
675
+ @column_names = t.column_names
676
+ self
677
+ end
678
+
679
+ alias_method :sub_table!, :reduce
680
+
681
+ # Returns an array of values for the given column name.
682
+ #
683
+ # Example:
684
+ #
685
+ # table = [[1,2],[3,4],[5,6]].to_table(%w[col1 col2])
686
+ # table.column("col1") #=> [1,3,5]
687
+ #
688
+ def column(name)
689
+ case(name)
690
+ when Integer
691
+ unless column_names.empty?
692
+ raise ArgumentError if name > column_names.length
693
+ end
694
+ else
695
+ raise ArgumentError unless column_names.include?(name)
696
+ end
697
+
698
+ map { |r| r[name] }
699
+ end
700
+
701
+ # Calculates sums. If a column name or index is given, it will try to
702
+ # convert each element of that column to an integer or float
703
+ # and add them together.
704
+ #
705
+ # If a block is given, it yields each Record so that you can do your own
706
+ # calculation.
707
+ #
708
+ # Example:
709
+ #
710
+ # table = [[1,2],[3,4],[5,6]].to_table(%w[col1 col2])
711
+ # table.sigma("col1") #=> 9
712
+ # table.sigma(0) #=> 9
713
+ # table.sigma { |r| r.col1 + r.col2 } #=> 21
714
+ # table.sigma { |r| r.col2 + 1 } #=> 15
715
+ #
716
+ def sigma(column=nil)
717
+ inject(0) { |s,r|
718
+ if column
719
+ s + if r.get(column).kind_of? Numeric
720
+ r.get(column)
721
+ else
722
+ r.get(column) =~ /\./ ? r.get(column).to_f : r.get(column).to_i
723
+ end
724
+ else
725
+ s + yield(r)
726
+ end
727
+ }
728
+ end
729
+
730
+ alias_method :sum, :sigma
731
+
732
+ # Returns a sorted table. If col_names is specified,
733
+ # the block is ignored and the table is sorted by the named columns.
734
+ #
735
+ # The second argument specifies sorting options. Currently only
736
+ # :order is supported. Default order is ascending, to sort decending
737
+ # use :order => :descending
738
+ #
739
+ # Example:
740
+ #
741
+ # table = [[4, 3], [2, 5], [7, 1]].to_table(%w[col1 col2 ])
742
+ #
743
+ # # returns a new table sorted by col1
744
+ # table.sort_rows_by {|r| r["col1"]}
745
+ #
746
+ # # returns a new table sorted by col1, in descending order
747
+ # table.sort_rows_by(nil, :order => :descending) {|r| r["col1"]}
748
+ #
749
+ # # returns a new table sorted by col2
750
+ # table.sort_rows_by(["col2"])
751
+ #
752
+ # # returns a new table sorted by col2, descending order
753
+ # table.sort_rows_by("col2", :order => :descending)
754
+ #
755
+ # # returns a new table sorted by col1, then col2
756
+ # table.sort_rows_by(["col1", "col2"])
757
+ #
758
+ # # returns a new table sorted by col1, then col2, in descending order
759
+ # table.sort_rows_by(["col1", "col2"], :order => descending)
760
+ #
761
+ def sort_rows_by(col_names=nil, options={}, &block)
762
+ # stabilizer is needed because of
763
+ # http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/170565
764
+ stabilizer = 0
765
+
766
+ nil_rows, sortable = partition do |r|
767
+ Array(col_names).any? { |c| r[c].nil? }
768
+ end
769
+
770
+ data_array =
771
+ if col_names
772
+ sortable.sort_by do |r|
773
+ stabilizer += 1
774
+ [Array(col_names).map {|col| r[col]}, stabilizer]
775
+ end
776
+ else
777
+ sortable.sort_by(&block)
778
+ end
779
+
780
+ data_array += nil_rows
781
+ data_array.reverse! if options[:order] == :descending
782
+
783
+ table = self.class.new( :data => data_array,
784
+ :column_names => @column_names,
785
+ :record_class => record_class )
786
+
787
+ return table
788
+ end
789
+
790
+ # Same as Table#sort_rows_by, but self modifying.
791
+ # See <tt>sort_rows_by</tt> for documentation.
792
+ #
793
+ def sort_rows_by!(col_names=nil,options={},&block)
794
+ table = sort_rows_by(col_names,options,&block)
795
+ @data = table.data
796
+ end
797
+
798
+ # Get an array of records from the Table limited by the criteria specified.
799
+ #
800
+ # Example:
801
+ #
802
+ # table = Table.new :data => [[1,2,3], [1,4,6], [4,5,6]],
803
+ # :column_names => %w[a b c]
804
+ # table.rows_with(:a => 1) #=> [[1,2,3], [1,4,6]]
805
+ # table.rows_with(:a => 1, :b => 4) #=> [[1,4,6]]
806
+ # table.rows_with_a(1) #=> [[1,2,3], [1,4,6]]
807
+ # table.rows_with(%w[a b]) {|a,b| [a,b] == [1,4] } #=> [[1,4,6]]
808
+ #
809
+ def rows_with(columns,&block)
810
+ select { |r|
811
+ if block
812
+ block[*(columns.map { |c| r.get(c) })]
813
+ else
814
+ columns.all? { |k,v| r.get(k) == v }
815
+ end
816
+ }
817
+ end
818
+
819
+ # Create a copy of the Table. Records will be copied as well.
820
+ #
821
+ # Example:
822
+ #
823
+ # one = Table.new :data => [[1,2], [3,4]],
824
+ # :column_names => %w[a b]
825
+ # two = one.dup
826
+ #
827
+ def initialize_copy(from)
828
+ @record_class = from.record_class.name
829
+ @column_names = from.column_names.dup
830
+ @data = []
831
+ from.data.each { |r| self << r.dup }
832
+ end
833
+
834
+ # Uses Ruport's built-in text formatter to render this Table into a String.
835
+ #
836
+ # Example:
837
+ #
838
+ # data = Table.new :data => [[1,2], [3,4]],
839
+ # :column_names => %w[a b]
840
+ # puts data.to_s
841
+ #
842
+ def to_s
843
+ as(:text)
844
+ end
845
+
846
+ # Convert the Table into a Group using the supplied group name.
847
+ #
848
+ # data = Table.new :data => [[1,2], [3,4]],
849
+ # :column_names => %w[a b]
850
+ # group = data.to_group("my_group")
851
+ #
852
+ def to_group(name=nil)
853
+ Group.new( :data => data,
854
+ :column_names => column_names,
855
+ :name => name,
856
+ :record_class => record_class )
857
+ end
858
+
859
+ # NOTE: does not respect tainted status
860
+ alias_method :clone, :dup
861
+
862
+ # Provides a shortcut for the <tt>as()</tt> method by converting a call to
863
+ # <tt>as(:format_name)</tt> into a call to <tt>to_format_name</tt>
864
+ #
865
+ # Also converts a call to <tt>rows_with_columnname</tt> to a call to
866
+ # <tt>rows_with(:columnname => args[0])</tt>.
867
+ #
868
+ def method_missing(id,*args,&block)
869
+ return as($1.to_sym,*args,&block) if id.to_s =~ /^to_(.*)/
870
+ return rows_with($1.to_sym => args[0]) if id.to_s =~ /^rows_with_(.*)/
871
+ super
872
+ end
873
+
874
+ def feed_element(row)
875
+ recordize(row)
876
+ end
877
+
878
+ private
879
+
880
+ def recordize(row)
881
+ case row
882
+ when Array
883
+ normalize_array(row)
884
+ when Hash
885
+ normalize_hash(row)
886
+ when record_class
887
+ recordize(normalize_record(row))
888
+ else
889
+ normalize_hash(row) rescue normalize_array(row)
890
+ end
891
+ end
892
+
893
+ def normalize_hash(hash_obj)
894
+ hash_obj = hash_obj.to_hash
895
+ raise ArgumentError unless @column_names
896
+ record_class.new(hash_obj, :attributes => @column_names)
897
+ end
898
+
899
+ def normalize_record(record)
900
+ record.send(column_names.empty? ? :to_a : :to_hash)
901
+ end
902
+
903
+ def normalize_array(array)
904
+ attributes = @column_names.empty? ? nil : @column_names
905
+ record_class.new(array.to_ary, :attributes => attributes)
906
+ end
907
+ end
908
+ end
909
+
910
+
911
+ module Kernel
912
+
913
+ # Shortcut interface for creating Data::Tables
914
+ #
915
+ # Examples:
916
+ #
917
+ # t = Table(%w[a b c]) #=> creates a new empty table w. cols a,b,c
918
+ # t = Table("a","b","c") #=> creates a new empty table w. cols a,b,c
919
+ #
920
+ # # allows building table inside of block, returns table object
921
+ # t = Table(%w[a b c]) { |t| t << [1,2,3] }
922
+ #
923
+ # # allows loading table from CSV
924
+ # # accepts all Data::Table.load options, including block (yields table,row)
925
+ #
926
+ # t = Table("foo.csv")
927
+ # t = Table("bar.csv", :has_names => false)
928
+ def Table(*args,&block)
929
+ table=
930
+ case(args[0])
931
+ when Array
932
+ opts = args[1] || {}
933
+ Ruport::Data::Table.new(f={:column_names => args[0]}.merge(opts),&block)
934
+ when /\.csv/
935
+ return Ruport::Data::Table.load(*args,&block)
936
+ when Hash
937
+ if file = args[0].delete(:file)
938
+ return Ruport::Data::Table.load(file,args[0],&block)
939
+ elsif string = args[0].delete(:string)
940
+ return Ruport::Data::Table.parse(string,args[0],&block)
941
+ else
942
+ return Ruport::Data::Table.new(args[0],&block)
943
+ end
944
+ else
945
+ Ruport::Data::Table.new(:data => [], :column_names => args,&block)
946
+ end
947
+
948
+ return table
949
+ end
950
+ end