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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -0
- data/Rakefile +2 -2
- data/fat_core.gemspec +19 -18
- data/lib/core_extensions/date/fat_core.rb +0 -1
- data/lib/fat_core.rb +5 -1
- data/lib/fat_core/column.rb +197 -0
- data/lib/fat_core/date.rb +192 -132
- data/lib/fat_core/enumerable.rb +1 -1
- data/lib/fat_core/evaluator.rb +43 -0
- data/lib/fat_core/hash.rb +1 -1
- data/lib/fat_core/nil.rb +4 -0
- data/lib/fat_core/numeric.rb +30 -17
- data/lib/fat_core/period.rb +58 -67
- data/lib/fat_core/range.rb +20 -25
- data/lib/fat_core/string.rb +95 -55
- data/lib/fat_core/symbol.rb +12 -14
- data/lib/fat_core/table.rb +515 -0
- data/lib/fat_core/version.rb +2 -2
- data/spec/example_files/goldberg.org +199 -0
- data/spec/example_files/wpcs.csv +92 -0
- data/spec/lib/array_spec.rb +1 -1
- data/spec/lib/date_spec.rb +5 -6
- data/spec/lib/enumerable_spec.rb +1 -1
- data/spec/lib/evaluator_spec.rb +34 -0
- data/spec/lib/hash_spec.rb +5 -5
- data/spec/lib/kernel_spec.rb +3 -3
- data/spec/lib/nil_spec.rb +2 -2
- data/spec/lib/numeric_spec.rb +22 -22
- data/spec/lib/period_spec.rb +11 -12
- data/spec/lib/range_spec.rb +50 -50
- data/spec/lib/string_spec.rb +71 -74
- data/spec/lib/symbol_spec.rb +3 -3
- data/spec/lib/table_spec.rb +659 -0
- data/spec/spec_helper.rb +1 -1
- metadata +28 -2
data/lib/fat_core/range.rb
CHANGED
@@ -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
|
59
|
-
([
|
56
|
+
return nil unless overlaps?(other)
|
57
|
+
([min, other.min].max..[max, other.max].min)
|
60
58
|
end
|
61
|
-
|
59
|
+
alias & intersection
|
62
60
|
|
63
61
|
def union(other)
|
64
|
-
return nil unless
|
65
|
-
([
|
62
|
+
return nil unless overlaps?(other) || contiguous?(other)
|
63
|
+
([min, other.min].min..[max, other.max].max)
|
66
64
|
end
|
67
|
-
|
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
|
-
|
77
|
-
raise
|
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
|
85
|
+
[(other.max.succ..max)]
|
89
86
|
elsif overlaps?(other) && other.max >= max
|
90
87
|
# (4..7) - (6..10) -> (4..5)
|
91
|
-
[(min
|
88
|
+
[(min..other.min.pred)]
|
92
89
|
else
|
93
|
-
[
|
90
|
+
[self]
|
94
91
|
end
|
95
92
|
end
|
96
|
-
|
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
|
-
|
108
|
-
|
109
|
-
|
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
|
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
|
-
[
|
137
|
+
[clone]
|
141
138
|
elsif spanned_by?(ranges)
|
142
139
|
[]
|
143
140
|
else
|
144
|
-
ranges = ranges.sort_by
|
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
|
167
|
+
ranges = ranges.sort_by(&:min)
|
173
168
|
overlaps = []
|
174
169
|
cur_point = nil
|
175
170
|
ranges.each do |rr|
|
data/lib/fat_core/string.rb
CHANGED
@@ -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
|
-
|
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 =
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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 =~
|
45
|
+
elsif str =~ %r{^\s*/}
|
48
46
|
re = str.to_regexp
|
49
|
-
if
|
50
|
-
$&
|
51
|
-
else
|
52
|
-
nil
|
53
|
-
end
|
47
|
+
$& if to_s =~ re
|
54
48
|
else
|
55
|
-
|
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 =~
|
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
|
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 =
|
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
|
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
|
-
|
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
|
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
|
142
|
+
if w =~ %r{c/o}i
|
149
143
|
# Care of
|
150
|
-
newwords.push(
|
151
|
-
elsif w =~
|
144
|
+
newwords.push('c/o')
|
145
|
+
elsif w =~ /^p\.?o\.?$/i
|
152
146
|
# Post office
|
153
|
-
newwords.push(
|
154
|
-
elsif w =~
|
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 =~
|
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 =~
|
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 =~
|
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 =~
|
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 =~
|
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 =~
|
174
|
-
#
|
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(
|
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
|
-
|
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
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
def
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
def
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
def
|
210
|
-
|
211
|
-
|
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
|
data/lib/fat_core/symbol.rb
CHANGED
@@ -1,17 +1,15 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
-
|
11
|
-
|
12
|
-
|
8
|
+
def as_sym
|
9
|
+
self
|
10
|
+
end
|
13
11
|
|
14
|
-
|
15
|
-
|
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
|