fat_core 1.0.3 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,8 +6,6 @@ class Range
6
6
  Range.new(min, other.max)
7
7
  elsif right_contiguous?(other)
8
8
  Range.new(other.min, max)
9
- else
10
- nil
11
9
  end
12
10
  end
13
11
 
@@ -55,16 +53,16 @@ class Range
55
53
  end
56
54
 
57
55
  def intersection(other)
58
- return nil unless self.overlaps?(other)
59
- ([self.min, other.min].max..[self.max, other.max].min)
56
+ return nil unless overlaps?(other)
57
+ ([min, other.min].max..[max, other.max].min)
60
58
  end
61
- alias_method :&, :intersection
59
+ alias & intersection
62
60
 
63
61
  def union(other)
64
- return nil unless self.overlaps?(other) || self.contiguous?(other)
65
- ([self.min, other.min].min..[self.max, other.max].max)
62
+ return nil unless overlaps?(other) || contiguous?(other)
63
+ ([min, other.min].min..[max, other.max].max)
66
64
  end
67
- alias_method :+, :union
65
+ alias + union
68
66
 
69
67
  # The difference method, -, removes the overlapping part of the other
70
68
  # argument from self. Because in the case where self is a superset of the
@@ -73,10 +71,9 @@ class Range
73
71
  # self is a subset of the other range, return an array of self
74
72
  def difference(other)
75
73
  unless max.respond_to?(:succ) && min.respond_to?(:pred) &&
76
- other.max.respond_to?(:succ) && other.min.respond_to?(:pred)
77
- raise "Range difference operation requires objects have pred and succ methods"
74
+ other.max.respond_to?(:succ) && other.min.respond_to?(:pred)
75
+ raise 'Range difference requires objects have pred and succ methods'
78
76
  end
79
- # return [self] unless self.overlaps?(other)
80
77
  if subset_of?(other)
81
78
  # (4..7) - (0..10)
82
79
  []
@@ -85,15 +82,15 @@ class Range
85
82
  [(min..other.min.pred), (other.max.succ..max)]
86
83
  elsif overlaps?(other) && other.min <= min
87
84
  # (4..7) - (2..5) -> (6..7)
88
- [(other.max.succ .. max)]
85
+ [(other.max.succ..max)]
89
86
  elsif overlaps?(other) && other.max >= max
90
87
  # (4..7) - (6..10) -> (4..5)
91
- [(min .. other.min.pred)]
88
+ [(min..other.min.pred)]
92
89
  else
93
- [ self ]
90
+ [self]
94
91
  end
95
92
  end
96
- alias_method :-, :difference
93
+ alias - difference
97
94
 
98
95
  # Return whether any of the ranges that are within self overlap one
99
96
  # another
@@ -104,9 +101,9 @@ class Range
104
101
  next unless overlaps?(r1)
105
102
  result =
106
103
  ranges.any? do |r2|
107
- r1.object_id != r2.object_id && overlaps?(r2) &&
108
- r1.overlaps?(r2)
109
- end
104
+ r1.object_id != r2.object_id && overlaps?(r2) &&
105
+ r1.overlaps?(r2)
106
+ end
110
107
  return true if result
111
108
  end
112
109
  end
@@ -117,7 +114,7 @@ class Range
117
114
  # without overlaps.
118
115
  def spanned_by?(ranges)
119
116
  joined_range = nil
120
- ranges.sort_by {|r| r.min}.each do |r|
117
+ ranges.sort_by(&:min).each do |r|
121
118
  unless joined_range
122
119
  joined_range = r
123
120
  next
@@ -137,11 +134,11 @@ class Range
137
134
  # array.
138
135
  def gaps(ranges)
139
136
  if ranges.empty?
140
- [self.clone]
137
+ [clone]
141
138
  elsif spanned_by?(ranges)
142
139
  []
143
140
  else
144
- ranges = ranges.sort_by {|r| r.min}
141
+ ranges = ranges.sort_by(&:min)
145
142
  gaps = []
146
143
  cur_point = min
147
144
  ranges.each do |rr|
@@ -155,9 +152,7 @@ class Range
155
152
  cur_point = rr.max.succ
156
153
  end
157
154
  end
158
- if cur_point <= max
159
- gaps << (cur_point..max)
160
- end
155
+ gaps << (cur_point..max) if cur_point <= max
161
156
  gaps
162
157
  end
163
158
  end
@@ -169,7 +164,7 @@ class Range
169
164
  if ranges.empty? || spanned_by?(ranges)
170
165
  []
171
166
  else
172
- ranges = ranges.sort_by {|r| r.min}
167
+ ranges = ranges.sort_by(&:min)
173
168
  overlaps = []
174
169
  cur_point = nil
175
170
  ranges.each do |rr|
@@ -4,7 +4,7 @@ class String
4
4
  # Remove leading and trailing white space and compress internal runs of
5
5
  # white space to a single space.
6
6
  def clean
7
- self.strip.squeeze(' ')
7
+ strip.squeeze(' ')
8
8
  end
9
9
 
10
10
  def distance(other, block_size: 1, max_distance: 10)
@@ -13,7 +13,7 @@ class String
13
13
  # will return max_distance+1 if the distance is bigger than that.
14
14
  # Here we subtract 1 so the max_distance also becomes the max
15
15
  # return value.
16
- dl.distance(self, other, block_size, max_distance-1)
16
+ dl.distance(self, other, block_size, max_distance - 1)
17
17
  end
18
18
 
19
19
  # See if self contains colon- or space-separated words that include
@@ -22,18 +22,16 @@ class String
22
22
  def fuzzy_match(other)
23
23
  # Remove periods, commas, and apostrophes
24
24
  other = other.gsub(/[\*.,']/, '')
25
- target = self.gsub(/[\*.,']/, '')
26
- matched_text = nil
25
+ target = gsub(/[\*.,']/, '')
27
26
  matchers = other.split(/[: ]+/)
28
- regexp_string = matchers.map {|m| ".*?#{Regexp.escape(m)}.*?"}.join('[: ]')
27
+ regexp_string = matchers.map { |m| ".*?#{Regexp.escape(m)}.*?" }.join('[: ]')
29
28
  regexp_string.sub!(/^\.\*\?/, '')
30
29
  regexp_string.sub!(/\.\*\?$/, '')
31
30
  regexp = /#{regexp_string}/i
32
- if match = regexp.match(target)
33
- matched_text = match[0]
34
- else
35
- matched_text = nil
36
- end
31
+ matched_text =
32
+ if (match = regexp.match(target))
33
+ match[0]
34
+ end
37
35
  matched_text
38
36
  end
39
37
 
@@ -44,15 +42,11 @@ class String
44
42
  def matches_with(str)
45
43
  if str.nil?
46
44
  nil
47
- elsif str =~ /^\s*\//
45
+ elsif str =~ %r{^\s*/}
48
46
  re = str.to_regexp
49
- if self.to_s =~ re
50
- $&
51
- else
52
- nil
53
- end
47
+ $& if to_s =~ re
54
48
  else
55
- self.to_s.fuzzy_match(str)
49
+ to_s.fuzzy_match(str)
56
50
  end
57
51
  end
58
52
 
@@ -60,7 +54,7 @@ class String
60
54
  # make the regular expression case-insensitive by default and extend the
61
55
  # modifier syntax to allow '/I' to indicate case-sensitive.
62
56
  def to_regexp
63
- if self =~ /^\s*\/([^\/]*)\/([Iixm]*)\s*$/
57
+ if self =~ %r{^\s*/([^/]*)/([Iixm]*)\s*$}
64
58
  body = $1
65
59
  opts = $2
66
60
  flags = Regexp::IGNORECASE
@@ -70,7 +64,7 @@ class String
70
64
  flags |= Regexp::EXTENDED if opts.include?('x')
71
65
  flags |= Regexp::MULTILINE if opts.include?('m')
72
66
  end
73
- flags = nil if flags == 0
67
+ flags = nil if flags.zero?
74
68
  Regexp.new(body, flags)
75
69
  else
76
70
  Regexp.new(self)
@@ -79,8 +73,8 @@ class String
79
73
 
80
74
  # Convert to symbol "Hello World" -> :hello_world
81
75
  def as_sym
82
- strip.squeeze(' ').gsub(/\s+/, '_').
83
- gsub(/[^_A-Za-z0-9]/, '').downcase.to_sym
76
+ strip.squeeze(' ').gsub(/\s+/, '_')
77
+ .gsub(/[^_A-Za-z0-9]/, '').downcase.to_sym
84
78
  end
85
79
 
86
80
  def as_string
@@ -94,7 +88,7 @@ class String
94
88
  return false
95
89
  end
96
90
 
97
- def wrap(width=70, hang=0)
91
+ def wrap(width = 70, hang = 0)
98
92
  offset = 0
99
93
  trip = 1
100
94
  result = ''
@@ -113,7 +107,7 @@ class String
113
107
  end
114
108
 
115
109
  def tex_quote
116
- r = self.dup
110
+ r = dup
117
111
  r = r.gsub(/[{]/, 'XzXzXobXzXzX')
118
112
  r = r.gsub(/[}]/, 'XzXzXcbXzXzX')
119
113
  r = r.gsub(/\\/, '\textbackslash{}')
@@ -124,7 +118,7 @@ class String
124
118
  r = r.gsub(/\>/, '\textgreater{}')
125
119
  r = r.gsub(/([_$&%#])/) { |m| '\\' + m }
126
120
  r = r.gsub('XzXzXobXzXzX', '\\{')
127
- r = r.gsub('XzXzXcbXzXzX', '\\}')
121
+ r.gsub('XzXzXcbXzXzX', '\\}')
128
122
  end
129
123
 
130
124
  def self.random(size = 8)
@@ -134,48 +128,48 @@ class String
134
128
  # Convert a string with an all-digit date to an iso string
135
129
  # E.g., "20090923" -> "2009-09-23"
136
130
  def digdate2iso
137
- self.sub(/(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
131
+ sub(/(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
138
132
  end
139
133
 
140
134
  def entitle!
141
- little_words = %w[ a an the and or in on under of from as by to ]
135
+ little_words = %w(a an the and or in on under of from as by to)
142
136
  newwords = []
143
137
  words = split(/\s+/)
144
138
  first_word = true
145
139
  num_words = words.length
146
140
  words.each_with_index do |w, k|
147
141
  last_word = (k + 1 == num_words)
148
- if w =~ %r[c/o]i
142
+ if w =~ %r{c/o}i
149
143
  # Care of
150
- newwords.push("c/o")
151
- elsif w =~ %r[^p\.?o\.?$]i
144
+ newwords.push('c/o')
145
+ elsif w =~ /^p\.?o\.?$/i
152
146
  # Post office
153
- newwords.push("P.O.")
154
- elsif w =~ %r[^[0-9]+(st|nd|rd|th)$]i
147
+ newwords.push('P.O.')
148
+ elsif w =~ /^[0-9]+(st|nd|rd|th)$/i
155
149
  # Ordinals
156
150
  newwords.push(w.downcase)
157
- elsif w =~ %r[^(cr|dr|st|rd|ave|pk|cir)$]i
151
+ elsif w =~ /^(cr|dr|st|rd|ave|pk|cir)$/i
158
152
  # Common abbrs to capitalize
159
153
  newwords.push(w.capitalize)
160
- elsif w =~ %r[^(us|ne|se|rr)$]i
154
+ elsif w =~ /^(us|ne|se|rr)$/i
161
155
  # Common 2-letter abbrs to upcase
162
156
  newwords.push(w.upcase)
163
- elsif w =~ %r[^[0-9].*$]i
157
+ elsif w =~ /^[0-9].*$/i
164
158
  # Other runs starting with numbers,
165
159
  # like 3-A
166
160
  newwords.push(w.upcase)
167
- elsif w =~ %r[^(N|S|E|W|NE|NW|SE|SW)$]i
161
+ elsif w =~ /^(N|S|E|W|NE|NW|SE|SW)$/i
168
162
  # Compass directions all caps
169
163
  newwords.push(w.upcase)
170
- elsif w =~ %r[^[^aeiouy]*$]i && w.size > 2
164
+ elsif w =~ /^[^aeiouy]*$/i && w.size > 2
171
165
  # All consonants and at least 3 chars, probably abbr
172
166
  newwords.push(w.upcase)
173
- elsif w =~ %r[^(\w+)-(\w+)$]i
174
- # Hypenated double word
167
+ elsif w =~ /^(\w+)-(\w+)$/i
168
+ # Hyphenated double word
175
169
  newwords.push($1.capitalize + '-' + $2.capitalize)
176
170
  elsif little_words.include?(w.downcase)
177
171
  # Only capitalize at beginning or end
178
- newwords.push((first_word or last_word) ? w.capitalize : w.downcase)
172
+ newwords.push(first_word || last_word ? w.capitalize : w.downcase)
179
173
  else
180
174
  # All else
181
175
  newwords.push(w.capitalize)
@@ -186,7 +180,17 @@ class String
186
180
  end
187
181
 
188
182
  def entitle
189
- self.dup.entitle!
183
+ dup.entitle!
184
+ end
185
+
186
+ # Format the string according to the given sprintf format.
187
+ def format_by(fmt)
188
+ return self unless fmt
189
+ begin
190
+ format fmt, self
191
+ rescue ArgumentError
192
+ return self
193
+ end
190
194
  end
191
195
 
192
196
  # Thanks to Eugene at stackoverflow for the following.
@@ -194,19 +198,55 @@ class String
194
198
  # colorized-output-breaks-linewrapping-with-readline
195
199
  # These color strings without confusing readline about the length of
196
200
  # the prompt string in the shell. (Unlike the rainbow routines)
197
- def console_red; colorize(self, "\001\e[1m\e[31m\002"); end
198
- def console_dark_red; colorize(self, "\001\e[31m\002"); end
199
- def console_green; colorize(self, "\001\e[1m\e[32m\002"); end
200
- def console_dark_green; colorize(self, "\001\e[32m\002"); end
201
- def console_yellow; colorize(self, "\001\e[1m\e[33m\002"); end
202
- def console_dark_yellow; colorize(self, "\001\e[33m\002"); end
203
- def console_blue; colorize(self, "\001\e[1m\e[34m\002"); end
204
- def console_dark_blue; colorize(self, "\001\e[34m\002"); end
205
- def console_purple; colorize(self, "\001\e[1m\e[35m\002"); end
206
-
207
- def console_def; colorize(self, "\001\e[1m\002"); end
208
- def console_bold; colorize(self, "\001\e[1m\002"); end
209
- def console_blink; colorize(self, "\001\e[5m\002"); end
210
-
211
- def colorize(text, color_code) "#{color_code}#{text}\001\e[0m\002" end
201
+ def console_red
202
+ colorize(self, "\001\e[1m\e[31m\002")
203
+ end
204
+
205
+ def console_dark_red
206
+ colorize(self, "\001\e[31m\002")
207
+ end
208
+
209
+ def console_green
210
+ colorize(self, "\001\e[1m\e[32m\002")
211
+ end
212
+
213
+ def console_dark_green
214
+ colorize(self, "\001\e[32m\002")
215
+ end
216
+
217
+ def console_yellow
218
+ colorize(self, "\001\e[1m\e[33m\002")
219
+ end
220
+
221
+ def console_dark_yellow
222
+ colorize(self, "\001\e[33m\002")
223
+ end
224
+
225
+ def console_blue
226
+ colorize(self, "\001\e[1m\e[34m\002")
227
+ end
228
+
229
+ def console_dark_blue
230
+ colorize(self, "\001\e[34m\002")
231
+ end
232
+
233
+ def console_purple
234
+ colorize(self, "\001\e[1m\e[35m\002")
235
+ end
236
+
237
+ def console_def
238
+ colorize(self, "\001\e[1m\002")
239
+ end
240
+
241
+ def console_bold
242
+ colorize(self, "\001\e[1m\002")
243
+ end
244
+
245
+ def console_blink
246
+ colorize(self, "\001\e[5m\002")
247
+ end
248
+
249
+ def colorize(text, color_code)
250
+ "#{color_code}#{text}\001\e[0m\002"
251
+ end
212
252
  end
@@ -1,17 +1,15 @@
1
- class Symbol
2
- # Convert to capitalized string: :hello_world -> "Hello World"
3
- def entitle
4
- to_s.gsub('_', ' ').split(' ')
5
- .join(' ')
6
- .entitle
7
- end
8
- alias :to_string :entitle
1
+ class Symbol
2
+ # Convert to capitalized string: :hello_world -> "Hello World"
3
+ def entitle
4
+ to_s.tr('_', ' ').split(' ').join(' ').entitle
5
+ end
6
+ alias to_string entitle
9
7
 
10
- def as_sym
11
- self
12
- end
8
+ def as_sym
9
+ self
10
+ end
13
11
 
14
- def tex_quote
15
- to_s.tex_quote
16
- end
12
+ def tex_quote
13
+ to_s.tex_quote
17
14
  end
15
+ end
@@ -0,0 +1,515 @@
1
+ module FatCore
2
+ # A container for a two-dimensional table. All cells in the table must be a
3
+ # String, a Date, a DateTime, a Bignum (or Integer), a BigDecimal, or a
4
+ # boolean. All columns must be of one of those types or be a string
5
+ # convertible into one of the supported types. It is considered an error if a
6
+ # single column contains cells of different types. Any cell that cannot be
7
+ # parsed as one of the numeric, date, or boolean types will have to_s applied
8
+ #
9
+ # You can initialize a Table in several ways:
10
+ #
11
+ # 1. with a Nil, which will return an empty table to which rows or columns can
12
+ # be added later,
13
+ # 2. with the name of a .csv file,
14
+ # 3. with the name of an .org file,
15
+ # 4. with an IO or StringIO object for either type of file, but in that case,
16
+ # you need to specify 'csv' or 'org' as the second argument to tell it what
17
+ # kind of file format to expect,
18
+ # 5. with an Array of Arrays,
19
+ # 6. with an Array of Hashes, all having the same keys, which become the names
20
+ # of the column heads,
21
+ # 7. with an Array of any objects that respond to .keys and .values methods,
22
+ # 8. with another Table object.
23
+ #
24
+ # In the case of an array of arrays, if the second array's first element is a
25
+ # string that looks like a rule separator, '-----------', '+----------', etc.,
26
+ # the headers will be taken from the first array. In the case of an array of
27
+ # Hashes or Hash-lime objects, the keys of the hashes will be used as the
28
+ # headers. It is assumed that all the hashes have the same keys.
29
+ #
30
+ # In the resulting Table, the headers are converted into symbols, with all
31
+ # spaces converted to underscore and everything down-cased. So, the heading,
32
+ # 'Two Words' becomes the hash header :two_words.
33
+ #
34
+ # An entire column can be retrieved by header from a Table, thus,
35
+ # #+BEGIN_EXAMPLE
36
+ # tab = Table.new("example.org")
37
+ # tab[:age].avg
38
+ # #+END_EXAMPLE
39
+ # will extract the entire ~:age~ column and compute its average, since Column
40
+ # objects respond to aggregate methods, such as ~sum~, ~min~, ~max~, and ~avg~.
41
+ class Table
42
+ attr_reader :columns, :footers
43
+
44
+ TYPES = %w(NilClass TrueClass FalseClass Date DateTime Numeric String)
45
+
46
+ def initialize(input = nil, ext = '.csv')
47
+ @columns = []
48
+ @footers = {}
49
+ return self if input.nil?
50
+ case input
51
+ when IO, StringIO
52
+ case ext
53
+ when /csv/i
54
+ from_csv(input)
55
+ when /org/i
56
+ from_org(input)
57
+ else
58
+ raise "Don't know how to read a '#{ext}' file."
59
+ end
60
+ when String
61
+ ext = File.extname(input).downcase
62
+ File.open(input, 'r') do |io|
63
+ case ext
64
+ when '.csv'
65
+ from_csv(io)
66
+ when '.org'
67
+ from_org(io)
68
+ else
69
+ raise "Don't know how to read a '#{ext}' file."
70
+ end
71
+ end
72
+ when Array
73
+ case input[0]
74
+ when Array
75
+ from_array_of_arrays(input)
76
+ when Hash
77
+ from_array_of_hashes(input)
78
+ when Table
79
+ from_table(input)
80
+ else
81
+ if input[0].respond_to?(:to_hash)
82
+ from_array_of_hashes(input)
83
+ else
84
+ raise ArgumentError,
85
+ "Cannot initialize Table with an array of #{input[0].class}"
86
+ end
87
+ end
88
+ else
89
+ raise ArgumentError,
90
+ "Cannot initialize Table with #{input.class}"
91
+ end
92
+ end
93
+
94
+ # Return the column with the given header.
95
+ def column(key)
96
+ columns.detect { |c| c.header == key.as_sym }
97
+ end
98
+
99
+ # Return the array of items of the column with the given header.
100
+ def [](key)
101
+ column(key)
102
+ end
103
+
104
+ def column?(key)
105
+ headers.include?(key.as_sym)
106
+ end
107
+
108
+ # Attr_reader as a plural
109
+ def types
110
+ columns.map(&:type)
111
+ end
112
+
113
+ # Return the headers for the table as an array of symbols.
114
+ def headers
115
+ columns.map(&:header)
116
+ end
117
+
118
+ # Return the rows of the table as an array of hashes, keyed by the headers.
119
+ def rows
120
+ rows = []
121
+ 0.upto(columns.first.items.last_i) do |rnum|
122
+ row = {}
123
+ columns.each do |col|
124
+ row[col.header] = col[rnum]
125
+ end
126
+ rows << row
127
+ end
128
+ rows
129
+ end
130
+
131
+ def empty?
132
+ rows.empty?
133
+ end
134
+
135
+ ############################################################################
136
+ # SQL look-alikes. The following methods are based on SQL equivalents and
137
+ # all return a new Table object rather than modifying the table in place.
138
+ ############################################################################
139
+
140
+ # Return a new Table sorted on the rows of this Table on the possibly
141
+ # multiple keys given in the array of syms in headers. Append a ! to the
142
+ # symbol name to indicate reverse sorting on that column.
143
+ def order_by(*sort_heads)
144
+ sort_heads = [sort_heads].flatten
145
+ rev_heads = sort_heads.select { |h| h.to_s.ends_with?('!') }
146
+ sort_heads = sort_heads.map { |h| h.to_s.sub(/\!\z/, '').to_sym }
147
+ rev_heads = rev_heads.map { |h| h.to_s.sub(/\!\z/, '').to_sym }
148
+ new_rows = rows.sort do |r1, r2|
149
+ key1 = sort_heads.map { |h| rev_heads.include?(h) ? r2[h] : r1[h] }
150
+ key2 = sort_heads.map { |h| rev_heads.include?(h) ? r1[h] : r2[h] }
151
+ key1 <=> key2
152
+ end
153
+ new_tab = Table.new
154
+ new_rows.each do |nrow|
155
+ new_tab.add_row(nrow)
156
+ end
157
+ new_tab
158
+ end
159
+
160
+ # Return a Table having the selected column expression. Each expression can
161
+ # be either a (1) symbol, (2) a hash of symbol => symbol, or (3) a hash of
162
+ # symbol => 'string', though the bare symbol arguments (1) must precede any
163
+ # hash arguments. Each expression results in a column in the resulting Table
164
+ # in the order given. The expressions are evaluated in order as well.
165
+ def select(*exps)
166
+ new_cols = {}
167
+ new_heads = []
168
+ exps.each do |exp|
169
+ case exp
170
+ when Symbol, String
171
+ h = exp.as_sym
172
+ raise "Header #{h} does not exist" unless headers.include?(h)
173
+ new_heads << h
174
+ new_cols[h] = Column.new(header: h,
175
+ type: column(h).type,
176
+ items: column(h).items)
177
+ when Hash
178
+ exp.each_pair do |key, xp|
179
+ case xp
180
+ when Symbol
181
+ h = xp.as_sym
182
+ raise "Header #{key} does not exist" unless column?(key)
183
+ new_heads << h
184
+ new_cols[h] = Column.new(header: h,
185
+ type: column(key).type,
186
+ items: column(key).items)
187
+ when String
188
+ # Evaluate xp in the context of a binding including a local
189
+ # variable for each original column with the name as the head and
190
+ # the value for the current row as the value and a local variable
191
+ # for each new column with the new name and the new value.
192
+ h = key.as_sym
193
+ new_heads << h
194
+ new_cols[h] = Column.new(header: h)
195
+ ev = Evaluator.new(vars: { row: 0 }, before: '@row += 1')
196
+ rows.each_with_index do |old_row, row_num|
197
+ new_row ||= {}
198
+ # Gather the new values computed so far for this row
199
+ new_vars = new_heads.zip(new_cols.keys
200
+ .map { |k| new_cols[k] }
201
+ .map { |c| c[row_num] })
202
+ vars = old_row.merge(Hash[new_vars])
203
+ # Now we have a hash, vars, of all local variables we want to be
204
+ # defined while evaluating expression xp as the value of column
205
+ # key in the new column.
206
+ new_row[h] = ev.evaluate(xp, vars: vars)
207
+ new_cols[h] << new_row[h]
208
+ end
209
+ else
210
+ raise 'Hash parameters to select must be a symbol or string'
211
+ end
212
+ end
213
+ else
214
+ raise 'Parameters to select must be a symbol, string, or hash'
215
+ end
216
+ end
217
+ result = Table.new
218
+ new_heads.each do |h|
219
+ result.add_column(new_cols[h])
220
+ end
221
+ result
222
+ end
223
+
224
+ # Return a Table containing only rows matching the where expression.
225
+ def where(expr)
226
+ expr = expr.to_s
227
+ result = Table.new
228
+ ev = Evaluator.new(vars: { row: 0 }, before: '@row += 1')
229
+ rows.each do |row|
230
+ result.add_row(row) if ev.evaluate(expr, vars: row)
231
+ end
232
+ result
233
+ end
234
+
235
+ # Return a Table that combines this table with another table. The headers of
236
+ # this table are used in the result. There must be the same number of
237
+ # columns of the same type in the two tables, or an exception will be
238
+ # thrown. Unlike in SQL, no duplicates are eliminated from the result.
239
+ def union(other)
240
+ unless columns.size == other.columns.size
241
+ raise 'Cannot apply union to tables with a different number of columns.'
242
+ end
243
+ result = Table.new
244
+ columns.each_with_index do |col, k|
245
+ result.add_column(col + other.columns[k])
246
+ end
247
+ result
248
+ end
249
+
250
+ # Return a Table in which all rows of the table are divided into groups
251
+ # where the value of all columns named as simple symbols are equal. All
252
+ # other columns are set to the result of aggregating the values of that
253
+ # column within the group according to the Column aggregate function (:sum,
254
+ # :min, :max, etc.) set in a hash parameter with the non-aggregate column
255
+ # name as a key and the symbol for the aggregate function as a value. For
256
+ # example, consider the following call:
257
+ #
258
+ # #+BEGIN_EXAMPLE
259
+ # tab.group_by(:date, :code, :price, shares: :sum, ).
260
+ # #+END_EXAMPLE
261
+ #
262
+ # The first three parameters are simple symbols, so the table is divided
263
+ # into groups of rows in which the value of :date, :code, and :price are
264
+ # equal. The :shares parameter is set to the aggregate function :sum, so it
265
+ # will appear in the result as the sum of all the :shares values in each
266
+ # group. Any non-aggregate columns that have no aggregate function set
267
+ # default to using the aggregate function :first. Note that because of the
268
+ # way Ruby parses parameters to a method call, all the grouping symbols must
269
+ # appear first in the parameter list.
270
+ def group_by(*exprs)
271
+ group_cols = []
272
+ agg_cols = {}
273
+ exprs.each do |xp|
274
+ case xp
275
+ when Symbol
276
+ group_cols << xp
277
+ when Hash
278
+ agg_cols = xp
279
+ else
280
+ raise "Cannot group by parameter '#{xp}"
281
+ end
282
+ end
283
+ default_agg_func = :first
284
+ default_cols = headers - group_cols - agg_cols.keys
285
+ default_cols.each do |h|
286
+ agg_cols[h] = default_agg_func
287
+ end
288
+
289
+ sorted_tab = order_by(group_cols)
290
+ groups = sorted_tab.rows.group_by do |r|
291
+ group_cols.map { |k| r[k] }
292
+ end
293
+ result_rows = []
294
+ groups.each_pair do |_vals, grp_rows|
295
+ result_rows << row_from_group(grp_rows, group_cols, agg_cols)
296
+ end
297
+ result = Table.new
298
+ result_rows.each do |row|
299
+ result.add_row(row)
300
+ end
301
+ result
302
+ end
303
+
304
+ private
305
+
306
+ def row_from_group(rows, grp_cols, agg_cols)
307
+ new_row = {}
308
+ grp_cols.each do |h|
309
+ new_row[h] = rows.first[h]
310
+ end
311
+ agg_cols.each_pair do |h, agg_func|
312
+ items = rows.map { |r| r[h] }
313
+ new_h = "#{agg_func}_#{h}"
314
+ new_row[new_h] = Column.new(header: h,
315
+ items: items,
316
+ type: column(h).type).send(agg_func)
317
+ end
318
+ new_row
319
+ end
320
+
321
+ ############################################################################
322
+ # Table output methods.
323
+ ############################################################################
324
+
325
+ public
326
+
327
+ def add_footer(label: 'Total', aggregate: :sum, heads: [])
328
+ foot = {}
329
+ heads.each do |h|
330
+ foot[h] = column(h).send(aggregate)
331
+ end
332
+ @footers[label.as_sym] = foot
333
+ self
334
+ end
335
+
336
+ def add_sum_footer(cols, label = 'Total')
337
+ add_footer(heads: cols)
338
+ end
339
+
340
+ def add_avg_footer(cols, label = 'Average')
341
+ add_footer(label: label, aggregate: :avg, heads: cols)
342
+ end
343
+
344
+ def add_min_footer(cols, label = 'Minimum')
345
+ add_footer(label: label, aggregate: :min, heads: cols)
346
+ end
347
+
348
+ def add_max_footer(cols, label = 'Maximum')
349
+ add_footer(label: label, aggregate: :max, heads: cols)
350
+ end
351
+
352
+ # This returns the table as an Array of Arrays with formatting applied.
353
+ # This would normally called after all calculations on the table are done
354
+ # and you want to return the results. The Array of Arrays structure is
355
+ # what org-mode src blocks will render as an org table in the buffer.
356
+ def to_org(formats: {})
357
+ result = []
358
+ header_row = []
359
+ headers.each do |hdr|
360
+ header_row << hdr.entitle
361
+ end
362
+ result << header_row
363
+ # This causes org to place an hline under the header row
364
+ result << nil unless header_row.empty?
365
+
366
+ rows.each do |row|
367
+ out_row = []
368
+ headers.each do |hdr|
369
+ out_row << row[hdr].format_by(formats[hdr])
370
+ end
371
+ result << out_row
372
+ end
373
+ footers.each_pair do |label, footer|
374
+ foot_row = []
375
+ columns.each do |hdr|
376
+ foot_row << footer[hdr].format_by(formats[hdr])
377
+ end
378
+ foot_row[0] = label.entitle
379
+ result << foot_row
380
+ end
381
+ result
382
+ end
383
+
384
+ # This returns the table as an Array of Arrays with formatting applied.
385
+ # This would normally called after all calculations on the table are done
386
+ # and you want to return the results. The Array of Arrays structure is
387
+ # what org-mode src blocks will render as an org table in the buffer.
388
+ def to_org(include: headers, exclude: [], formats: {})
389
+ # Allow include and exclude to be a single symbol or an array of symbols
390
+ # and compute the displayed columns.
391
+ columns = [include].flatten
392
+ exclude = [exclude].flatten
393
+ columns = include - exclude
394
+
395
+ result = []
396
+
397
+ header_row = []
398
+ columns.each do |hdr|
399
+ header_row << hdr.entitle
400
+ end
401
+ result << header_row
402
+
403
+ result << nil unless header_row.empty?
404
+
405
+ rows.each do |row|
406
+ out_row = []
407
+ columns.each do |hdr|
408
+ out_row << row[hdr].format_by(formats[hdr])
409
+ end
410
+ result << out_row
411
+ end
412
+
413
+ footers.each_pair do |label, footer|
414
+ foot_row = []
415
+ columns.each do |hdr|
416
+ foot_row << footer[hdr].format_by(formats[hdr])
417
+ end
418
+ foot_row[0] = label.entitle
419
+ result << foot_row
420
+ end
421
+ result
422
+ end
423
+ ############################################################################
424
+ # Table construction methods.
425
+ ############################################################################
426
+
427
+ # Add a row represented by a Hash having the headers as keys. All tables
428
+ # should be built ultimately using this method as a primitive.
429
+ def add_row(row)
430
+ row.each_pair do |k, v|
431
+ key = k.as_sym
432
+ columns << Column.new(header: k) unless column?(k)
433
+ column(key) << v
434
+ end
435
+ self
436
+ end
437
+
438
+ def <<(row)
439
+ add_row(row)
440
+ end
441
+
442
+ def add_column(col)
443
+ raise "Table already has a column with header '#{col.header}'" if column?(col.header)
444
+ columns << col
445
+ self
446
+ end
447
+
448
+ private
449
+
450
+ # Construct table from an array of hashes or an array of any object that can
451
+ # respond to #to_hash.
452
+ def from_array_of_hashes(rows)
453
+ rows.each do |row|
454
+ add_row(row.to_hash)
455
+ end
456
+ self
457
+ end
458
+
459
+ def from_array_of_arrays(rows)
460
+ headers = []
461
+ if rows[0].any? { |itm| itm.to_s.number? }
462
+ headers = (1..rows[0].size).to_a.map { |k| "col#{k}".as_sym }
463
+ first_data_row = 0
464
+ else
465
+ # Use first row 0 as headers
466
+ headers = rows[0].map(&:as_sym)
467
+ first_data_row = 1
468
+ end
469
+ hrule_re = /\A\s*\|[-+]+/
470
+ rows[first_data_row..-1].each do |row|
471
+ next if row[0] =~ hrule_re
472
+ row = row.map { |s| s.to_s.strip }
473
+ hash_row = Hash[headers.zip(row)]
474
+ add_row(hash_row)
475
+ end
476
+ self
477
+ end
478
+
479
+ def from_csv(io)
480
+ ::CSV.new(io, headers: true, header_converters: :symbol,
481
+ skip_blanks: true).each do |row|
482
+ add_row(row.to_hash)
483
+ end
484
+ self
485
+ end
486
+
487
+ # Form rows of table by reading the first table found in the org file.
488
+ def from_org(io)
489
+ table_re = /\A\s*\|/
490
+ hrule_re = /\A\s*\|[-+]+/
491
+ rows = []
492
+ table_found = false
493
+ header_found = false
494
+ io.each do |line|
495
+ unless table_found
496
+ # Skip through the file until a table is found
497
+ next unless line =~ table_re
498
+ table_found = true
499
+ end
500
+ break unless line =~ table_re
501
+ if !header_found && line =~ hrule_re
502
+ header_found = true
503
+ next
504
+ elsif header_found && line =~ hrule_re
505
+ # Stop reading at the second hline
506
+ break
507
+ else
508
+ line = line.sub(/\A\s*\|/, '').sub(/\|\s*\z/, '')
509
+ rows << line.split('|')
510
+ end
511
+ end
512
+ from_array_of_arrays(rows)
513
+ end
514
+ end
515
+ end