fat_table 0.4.2 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.org +1334 -457
- data/README.rdoc +1 -2
- data/TODO.org +17 -10
- data/examples/create_trans.sql +14 -0
- data/examples/quick.pdf +0 -0
- data/examples/quick.png +0 -0
- data/examples/quick.ppm +0 -0
- data/examples/quick.tex +8 -0
- data/examples/quick_small.png +0 -0
- data/examples/quicktable.tex +123 -0
- data/examples/trades.db +0 -0
- data/examples/trans.csv +13 -0
- data/fat_table.gemspec +1 -0
- data/lib/ext/array.rb +15 -0
- data/lib/fat_table/column.rb +69 -206
- data/lib/fat_table/convert.rb +173 -0
- data/lib/fat_table/evaluator.rb +7 -0
- data/lib/fat_table/footer.rb +228 -0
- data/lib/fat_table/formatters/formatter.rb +191 -162
- data/lib/fat_table/formatters/latex_formatter.rb +9 -7
- data/lib/fat_table/table.rb +188 -45
- data/lib/fat_table/version.rb +1 -1
- data/lib/fat_table.rb +5 -2
- data/md/README.md +1 -2
- metadata +28 -2
@@ -0,0 +1,173 @@
|
|
1
|
+
module FatTable
|
2
|
+
module Convert
|
3
|
+
# Convert val to the type of key, a ruby class constant, such as Date,
|
4
|
+
# Numeric, etc. If type is NilClass, the type is open, and a non-blank val
|
5
|
+
# will attempt conversion to one of the allowed types, typing it as a String
|
6
|
+
# if no other type is recognized. If the val is blank, and the type is nil,
|
7
|
+
# the Column type remains open. If the val is nil or a blank and the type is
|
8
|
+
# already determined, the val is set to nil, and should be filtered from any
|
9
|
+
# Column computations. If the val is non-blank and the Column type
|
10
|
+
# determined, raise an error if the val cannot be converted to the Column
|
11
|
+
# type. Otherwise, returns the converted val as an object of the correct
|
12
|
+
# class.
|
13
|
+
def self.convert_to_type(val, type)
|
14
|
+
case type
|
15
|
+
when 'NilClass'
|
16
|
+
if val != false && val.blank?
|
17
|
+
# Leave the type of the Column open. Unfortunately, false counts as
|
18
|
+
# blank and we don't want it to. It should be classified as a boolean.
|
19
|
+
new_val = nil
|
20
|
+
else
|
21
|
+
# Only non-blank values are allowed to set the type of the Column
|
22
|
+
bool_val = convert_to_boolean(val)
|
23
|
+
new_val =
|
24
|
+
if bool_val.nil?
|
25
|
+
convert_to_date_time(val) ||
|
26
|
+
convert_to_numeric(val) ||
|
27
|
+
convert_to_string(val)
|
28
|
+
else
|
29
|
+
bool_val
|
30
|
+
end
|
31
|
+
end
|
32
|
+
new_val
|
33
|
+
when 'Boolean'
|
34
|
+
if val.is_a?(String) && val.blank? || val.nil?
|
35
|
+
nil
|
36
|
+
else
|
37
|
+
new_val = convert_to_boolean(val)
|
38
|
+
if new_val.nil?
|
39
|
+
msg = "attempt to add '#{val}' to a column already typed as #{type}"
|
40
|
+
raise UserError, msg
|
41
|
+
end
|
42
|
+
new_val
|
43
|
+
end
|
44
|
+
when 'DateTime'
|
45
|
+
if val.blank?
|
46
|
+
nil
|
47
|
+
else
|
48
|
+
new_val = convert_to_date_time(val)
|
49
|
+
if new_val.nil?
|
50
|
+
msg = "attempt to add '#{val}' to a column already typed as #{type}"
|
51
|
+
raise UserError, msg
|
52
|
+
end
|
53
|
+
new_val
|
54
|
+
end
|
55
|
+
when 'Numeric'
|
56
|
+
if val.blank?
|
57
|
+
nil
|
58
|
+
else
|
59
|
+
new_val = convert_to_numeric(val)
|
60
|
+
if new_val.nil?
|
61
|
+
msg = "attempt to add '#{val}' to a column already typed as #{type}"
|
62
|
+
raise UserError, msg
|
63
|
+
end
|
64
|
+
new_val
|
65
|
+
end
|
66
|
+
when 'String'
|
67
|
+
if val.nil?
|
68
|
+
nil
|
69
|
+
else
|
70
|
+
new_val = convert_to_string(val)
|
71
|
+
if new_val.nil?
|
72
|
+
msg = "attempt to add '#{val}' to a column already typed as #{type}"
|
73
|
+
raise UserError, msg
|
74
|
+
end
|
75
|
+
new_val
|
76
|
+
end
|
77
|
+
else
|
78
|
+
raise UserError, "Mysteriously, column has unknown type '#{type}'"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Convert the val to a boolean if it looks like one, otherwise return nil.
|
83
|
+
# Any boolean or a string of t, f, true, false, y, n, yes, or no, regardless
|
84
|
+
# of case is assumed to be a boolean.
|
85
|
+
def self.convert_to_boolean(val)
|
86
|
+
return val if val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
87
|
+
val = val.to_s.clean
|
88
|
+
return nil if val.blank?
|
89
|
+
if val.match?(/\A(false|f|n|no)\z/i)
|
90
|
+
false
|
91
|
+
elsif val.match?(/\A(true|t|y|yes)\z/i)
|
92
|
+
true
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
ISO_DATE_RE = %r{(?<yr>\d\d\d\d)[-\/]
|
97
|
+
(?<mo>\d\d?)[-\/]
|
98
|
+
(?<dy>\d\d?)\s*
|
99
|
+
(T?\s*\d\d:\d\d(:\d\d)?
|
100
|
+
([-+](\d\d?)(:\d\d?))?)?}x
|
101
|
+
|
102
|
+
AMR_DATE_RE = %r{(?<dy>\d\d?)[-/](?<mo>\d\d?)[-/](?<yr>\d\d\d\d)\s*
|
103
|
+
(?<tm>T\d\d:\d\d:\d\d(\+\d\d:\d\d)?)?}x
|
104
|
+
|
105
|
+
# A Date like 'Tue, 01 Nov 2016' or 'Tue 01 Nov 2016' or '01 Nov 2016'.
|
106
|
+
# These are emitted by Postgresql, so it makes from_sql constructor
|
107
|
+
# possible without special formatting of the dates.
|
108
|
+
INV_DATE_RE = %r{((mon|tue|wed|thu|fri|sat|sun)[a-zA-z]*,?)?\s+ # looks like dow
|
109
|
+
(?<dy>\d\d?)\s+ # one or two-digit day
|
110
|
+
(?<mo_name>[jfmasondJFMASOND][A-Za-z]{2,})\s+ # looks like a month name
|
111
|
+
(?<yr>\d\d\d\d) # and a 4-digit year
|
112
|
+
}xi
|
113
|
+
|
114
|
+
# Convert the val to a DateTime if it is either a DateTime, a Date, a Time, or a
|
115
|
+
# String that can be parsed as a DateTime, otherwise return nil. It only
|
116
|
+
# recognizes strings that contain a something like '2016-01-14' or '2/12/1985'
|
117
|
+
# within them, otherwise DateTime.parse would treat many bare numbers as dates,
|
118
|
+
# such as '2841381', which it would recognize as a valid date, but the user
|
119
|
+
# probably does not intend it to be so treated.
|
120
|
+
def self.convert_to_date_time(val)
|
121
|
+
return val if val.is_a?(DateTime)
|
122
|
+
return val if val.is_a?(Date)
|
123
|
+
return val.to_datetime if val.is_a?(Time)
|
124
|
+
begin
|
125
|
+
str = val.to_s.clean
|
126
|
+
return nil if str.blank?
|
127
|
+
|
128
|
+
if str.match(ISO_DATE_RE)
|
129
|
+
date = DateTime.parse(val)
|
130
|
+
elsif str =~ AMR_DATE_RE
|
131
|
+
date = DateTime.new(Regexp.last_match[:yr].to_i,
|
132
|
+
Regexp.last_match[:mo].to_i,
|
133
|
+
Regexp.last_match[:dy].to_i)
|
134
|
+
elsif str =~ INV_DATE_RE
|
135
|
+
mo = Date.mo_name_to_num(last_match[:mo_name])
|
136
|
+
date = DateTime.new(Regexp.last_match[:yr].to_i, mo,
|
137
|
+
Regexp.last_match[:dy].to_i)
|
138
|
+
else
|
139
|
+
return nil
|
140
|
+
end
|
141
|
+
# val = val.to_date if
|
142
|
+
date.seconds_since_midnight.zero? ? date.to_date : date
|
143
|
+
rescue ArgumentError
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Convert the val to a Numeric if is already a Numeric or is a String that
|
149
|
+
# looks like one. Any Float is promoted to a BigDecimal. Otherwise return
|
150
|
+
# nil.
|
151
|
+
def self.convert_to_numeric(val)
|
152
|
+
return BigDecimal(val, Float::DIG) if val.is_a?(Float)
|
153
|
+
return val if val.is_a?(Numeric)
|
154
|
+
# Eliminate any commas, $'s (or other currency symbol), or _'s.
|
155
|
+
cursym = Regexp.quote(FatTable.currency_symbol)
|
156
|
+
clean_re = /[,_#{cursym}]/
|
157
|
+
val = val.to_s.clean.gsub(clean_re, '')
|
158
|
+
return nil if val.blank?
|
159
|
+
case val
|
160
|
+
when /(\A[-+]?\d+\.\d*\z)|(\A[-+]?\d*\.\d+\z)/
|
161
|
+
BigDecimal(val.to_s.clean)
|
162
|
+
when /\A[-+]?[\d]+\z/
|
163
|
+
val.to_i
|
164
|
+
when %r{\A(?<nm>[-+]?\d+)\s*[:/]\s*(?<dn>[-+]?\d+)\z}
|
165
|
+
Rational(Regexp.last_match[:nm], Regexp.last_match[:dn])
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def self.convert_to_string(val)
|
170
|
+
val.to_s
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
data/lib/fat_table/evaluator.rb
CHANGED
@@ -49,6 +49,13 @@ module FatTable
|
|
49
49
|
# Hash parameter +locals+ are available to the expression.
|
50
50
|
def evaluate(expr = '', locals: {})
|
51
51
|
eval(expr, local_vars(binding, locals))
|
52
|
+
rescue NoMethodError, TypeError => ex
|
53
|
+
if ex.to_s =~ /for nil:NilClass|nil can't be coerced/
|
54
|
+
# Likely one of the locals was nil, so let nil be the result.
|
55
|
+
return nil
|
56
|
+
else
|
57
|
+
raise ex
|
58
|
+
end
|
52
59
|
end
|
53
60
|
|
54
61
|
private
|
@@ -0,0 +1,228 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FatTable
|
4
|
+
class Footer
|
5
|
+
attr_reader :table, :label, :label_col, :values, :group
|
6
|
+
|
7
|
+
###########################################################################
|
8
|
+
# Constructors
|
9
|
+
###########################################################################
|
10
|
+
|
11
|
+
# :category: Constructors
|
12
|
+
|
13
|
+
# Initialize a labeled footer, optionally specifying a column for the
|
14
|
+
# label and whether the footer is to be a group footer. One or more values
|
15
|
+
# for the footer are added later with the #add_value method.
|
16
|
+
def initialize(label = 'Total', table, label_col: nil, group: false)
|
17
|
+
@label = label
|
18
|
+
unless table.is_a?(Table)
|
19
|
+
raise ArgumentError, 'Footer.new needs a table argument'
|
20
|
+
end
|
21
|
+
if label_col.nil?
|
22
|
+
@label_col = table.headers.first
|
23
|
+
else
|
24
|
+
unless table.headers.include?(label_col.as_sym)
|
25
|
+
raise ArgumentError, "Footer.new label column '#{label_col}' not a header of table."
|
26
|
+
end
|
27
|
+
@label_col = label_col.as_sym
|
28
|
+
end
|
29
|
+
@table = table
|
30
|
+
@group = group
|
31
|
+
@group_cols = {}
|
32
|
+
@values = {}
|
33
|
+
if group
|
34
|
+
@values[@label_col] = []
|
35
|
+
table.number_of_groups.times do
|
36
|
+
@values[@label_col] << @label
|
37
|
+
end
|
38
|
+
else
|
39
|
+
@values[@label_col] = [@label]
|
40
|
+
end
|
41
|
+
make_accessor_methods
|
42
|
+
end
|
43
|
+
|
44
|
+
# :category: Constructors
|
45
|
+
|
46
|
+
# Add a value to a footer for the footer's table at COL. The value of the
|
47
|
+
# footer is determined by AGG. If it is a symbol, such as :sum or :avg,
|
48
|
+
# it must be a valid aggregating function and the value is determined by
|
49
|
+
# applying the aggregate to the columns in the table, or in a group
|
50
|
+
# footer, to the rows in the group. If AGG is not a symbol, but it can be
|
51
|
+
# converted to a valid type for a FatTable::Table column, then it is so
|
52
|
+
# converted and the value set to it directly without invoking an aggregate
|
53
|
+
# function.
|
54
|
+
def add_value(col, agg)
|
55
|
+
col = col.as_sym
|
56
|
+
if col.nil?
|
57
|
+
raise ArgumentError, 'Footer#add_value col is nil but must name a table column.'
|
58
|
+
else
|
59
|
+
unless table.headers.include?(col.as_sym)
|
60
|
+
raise ArgumentError, "Footer#add_value col '#{col}' not a header of the table."
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
if group
|
65
|
+
number_of_groups.times do |k|
|
66
|
+
values[col] ||= []
|
67
|
+
values[col] << calc_val(agg, col, k)
|
68
|
+
end
|
69
|
+
else
|
70
|
+
values[col] = [calc_val(agg, col)]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# :category: Accessors
|
75
|
+
|
76
|
+
# Return the value of under the +key+ header, or if this is a group
|
77
|
+
# footer, return an array of the values for all the groups under the +key+
|
78
|
+
# header.
|
79
|
+
def [](key)
|
80
|
+
key = key.as_sym
|
81
|
+
if values.keys.include?(key)
|
82
|
+
if group
|
83
|
+
values[key]
|
84
|
+
else
|
85
|
+
values[key].last
|
86
|
+
end
|
87
|
+
elsif table.headers.include?(label_col.as_sym)
|
88
|
+
nil
|
89
|
+
else
|
90
|
+
raise ArgumentError, "No column header '#{key}' in footer table"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# :category: Accessors
|
95
|
+
|
96
|
+
# Return the total number of groups in the table to which this footer
|
97
|
+
# belongs. Note that if the table has both group footers and normal
|
98
|
+
# footers, this will return the number of groups even for a normal footer.
|
99
|
+
def number_of_groups
|
100
|
+
table.number_of_groups
|
101
|
+
end
|
102
|
+
|
103
|
+
# :category: Accessors
|
104
|
+
|
105
|
+
# Return a FatTable::Column object for the header h and, if a group, the
|
106
|
+
# kth group.
|
107
|
+
def column(h, k = nil)
|
108
|
+
if group && k.nil?
|
109
|
+
raise ArgumentError, 'Footer#column(h, k) missing the group number argument k'
|
110
|
+
end
|
111
|
+
if group
|
112
|
+
k.nil? ? @group_cols[h] : @group_cols[h][k]
|
113
|
+
else
|
114
|
+
table.column(h)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# :category: Accessors
|
119
|
+
|
120
|
+
# Return an Array of the values for the header h and, if a group, for the
|
121
|
+
# kth group.
|
122
|
+
def items(h, k = nil)
|
123
|
+
column(h, k).items
|
124
|
+
end
|
125
|
+
|
126
|
+
# :category: Accessors
|
127
|
+
|
128
|
+
# Return a Hash with a key for each column header mapped to the footer
|
129
|
+
# value for that column, nil for unused columns. Use the key +k+ to
|
130
|
+
# specify which group to access in the case of a group footer.
|
131
|
+
def to_h(k = nil)
|
132
|
+
hsh = {}
|
133
|
+
if group
|
134
|
+
table.headers.each do |h|
|
135
|
+
hsh[h] = values[h] ? values[h][k] : nil
|
136
|
+
end
|
137
|
+
else
|
138
|
+
table.headers.each do |h|
|
139
|
+
hsh[h] =
|
140
|
+
if values[h]
|
141
|
+
values[h].first
|
142
|
+
else
|
143
|
+
nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
hsh
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
# Evaluate the given agg for the header col and, in the case of a group
|
153
|
+
# footer, the group k.
|
154
|
+
def calc_val(agg, col, k = nil)
|
155
|
+
column =
|
156
|
+
if group
|
157
|
+
@group_cols[col] ||= table.group_cols(col)
|
158
|
+
@group_cols[col][k]
|
159
|
+
else
|
160
|
+
table.column(col)
|
161
|
+
end
|
162
|
+
|
163
|
+
case agg
|
164
|
+
when Symbol
|
165
|
+
column.send(agg)
|
166
|
+
when String
|
167
|
+
begin
|
168
|
+
converted_val = Convert.convert_to_type(agg, column.type)
|
169
|
+
rescue UserError
|
170
|
+
converted_val = false
|
171
|
+
end
|
172
|
+
if converted_val
|
173
|
+
converted_val
|
174
|
+
else
|
175
|
+
agg
|
176
|
+
end
|
177
|
+
when column.type.constantize
|
178
|
+
agg
|
179
|
+
when Proc
|
180
|
+
result =
|
181
|
+
if group
|
182
|
+
unless agg.arity == 3
|
183
|
+
msg = 'a lambda used in a group footer must have three arguments: (f, c, k)'
|
184
|
+
raise ArgumentError, msg
|
185
|
+
end
|
186
|
+
agg.call(self, col, k)
|
187
|
+
else
|
188
|
+
unless agg.arity == 2
|
189
|
+
msg = 'a lambda used in a non-group footer must have two arguments: (f, c)'
|
190
|
+
raise ArgumentError, msg
|
191
|
+
end
|
192
|
+
agg.call(self, col)
|
193
|
+
end
|
194
|
+
# Make sure the result returned can be inserted into footer field.
|
195
|
+
case result
|
196
|
+
when Symbol, String
|
197
|
+
calc_val(result, col, k)
|
198
|
+
when column.type.constantize
|
199
|
+
result
|
200
|
+
else
|
201
|
+
raise ArgumentError, "lambda cannot return an object of class #{result.class}"
|
202
|
+
end
|
203
|
+
else
|
204
|
+
agg
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Define an accessor method for each table header that returns the footer
|
209
|
+
# value for that column, and in the case of a group footer, either returns
|
210
|
+
# the array of values or take an optional index k to return the value for
|
211
|
+
# the k-th group.
|
212
|
+
def make_accessor_methods
|
213
|
+
table.headers.each do |attribute|
|
214
|
+
self.class.define_method attribute do |k = nil|
|
215
|
+
if group
|
216
|
+
if k.nil?
|
217
|
+
values[attribute]
|
218
|
+
else
|
219
|
+
values[attribute][k]
|
220
|
+
end
|
221
|
+
else
|
222
|
+
values[attribute].last
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|