fat_table 0.3.3 → 0.5.1
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/.rspec +2 -1
- data/.rubocop.yml +3 -5
- 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 +2 -2
- data/lib/ext/array.rb +15 -0
- data/lib/fat_table/column.rb +71 -208
- 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 +200 -163
- data/lib/fat_table/formatters/latex_formatter.rb +9 -7
- data/lib/fat_table/table.rb +229 -57
- data/lib/fat_table/version.rb +1 -1
- data/lib/fat_table.rb +5 -2
- data/md/README.md +1 -2
- metadata +30 -18
@@ -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
|