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.
- 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
|