fat_table 0.4.0 → 0.5.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,174 @@
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 IncompatibleTypeError, 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 IncompatibleTypeError, 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 IncompatibleTypeError, 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 IncompatibleTypeError, msg
74
+ end
75
+ new_val
76
+ end
77
+ else
78
+ raise LogicError, "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
+
125
+ begin
126
+ str = val.to_s.clean
127
+ return nil if str.blank?
128
+
129
+ if str.match(ISO_DATE_RE)
130
+ date = DateTime.parse(val)
131
+ elsif str =~ AMR_DATE_RE
132
+ date = DateTime.new(Regexp.last_match[:yr].to_i,
133
+ Regexp.last_match[:mo].to_i,
134
+ Regexp.last_match[:dy].to_i)
135
+ elsif str =~ INV_DATE_RE
136
+ mo = Date.mo_name_to_num(last_match[:mo_name])
137
+ date = DateTime.new(Regexp.last_match[:yr].to_i, mo,
138
+ Regexp.last_match[:dy].to_i)
139
+ else
140
+ return nil
141
+ end
142
+ # val = val.to_date if
143
+ date.seconds_since_midnight.zero? ? date.to_date : date
144
+ rescue ArgumentError
145
+ nil
146
+ end
147
+ end
148
+
149
+ # Convert the val to a Numeric if is already a Numeric or is a String that
150
+ # looks like one. Any Float is promoted to a BigDecimal. Otherwise return
151
+ # nil.
152
+ def self.convert_to_numeric(val)
153
+ return BigDecimal(val, Float::DIG) if val.is_a?(Float)
154
+ return val if val.is_a?(Numeric)
155
+ # Eliminate any commas, $'s (or other currency symbol), or _'s.
156
+ cursym = Regexp.quote(FatTable.currency_symbol)
157
+ clean_re = /[,_#{cursym}]/
158
+ val = val.to_s.clean.gsub(clean_re, '')
159
+ return nil if val.blank?
160
+ case val
161
+ when /(\A[-+]?\d+\.\d*\z)|(\A[-+]?\d*\.\d+\z)/
162
+ BigDecimal(val.to_s.clean)
163
+ when /\A[-+]?[\d]+\z/
164
+ val.to_i
165
+ when %r{\A(?<nm>[-+]?\d+)\s*[:/]\s*(?<dn>[-+]?\d+)\z}
166
+ Rational(Regexp.last_match[:nm], Regexp.last_match[:dn])
167
+ end
168
+ end
169
+
170
+ def self.convert_to_string(val)
171
+ val.to_s
172
+ end
173
+ end
174
+ end
@@ -9,6 +9,10 @@ module FatTable
9
9
  # cannot correct.
10
10
  class LogicError < StandardError; end
11
11
 
12
+ # Raised when attempting to add an incompatible type to an already-typed
13
+ # Column.
14
+ class IncompatibleTypeError < UserError; end
15
+
12
16
  # Raised when an external resource is not available due to caller or
13
17
  # programmer error or some failure of the external resource to be available.
14
18
  class TransientError < StandardError; end
@@ -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