fat_core 1.0.3 → 1.2.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.
@@ -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