reporter 0.0.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.
- data/Gemfile +4 -0
- data/MIT-LICENSE +21 -0
- data/README.markdown +310 -0
- data/Rakefile +62 -0
- data/VERSION +1 -0
- data/lib/reporter/data_set.rb +53 -0
- data/lib/reporter/data_source/active_record_source.rb +136 -0
- data/lib/reporter/data_source/scoping.rb +153 -0
- data/lib/reporter/data_source.rb +30 -0
- data/lib/reporter/data_structure.rb +41 -0
- data/lib/reporter/field/average_field.rb +7 -0
- data/lib/reporter/field/base.rb +21 -0
- data/lib/reporter/field/calculation_field.rb +32 -0
- data/lib/reporter/field/count_field.rb +9 -0
- data/lib/reporter/field/field.rb +25 -0
- data/lib/reporter/field/formula_field.rb +24 -0
- data/lib/reporter/field/sum_field.rb +7 -0
- data/lib/reporter/formula.rb +371 -0
- data/lib/reporter/result_row.rb +37 -0
- data/lib/reporter/scope/base.rb +37 -0
- data/lib/reporter/scope/date_scope.rb +109 -0
- data/lib/reporter/scope/reference_scope.rb +154 -0
- data/lib/reporter/support/time_range.rb +62 -0
- data/lib/reporter/time_iterator.rb +85 -0
- data/lib/reporter/time_optimized_result_row.rb +39 -0
- data/lib/reporter/value.rb +36 -0
- metadata +139 -0
@@ -0,0 +1,371 @@
|
|
1
|
+
# Formula parser and calculator
|
2
|
+
# Author:: Matthijs Groen
|
3
|
+
#
|
4
|
+
# This class has two main functions:
|
5
|
+
# 1. to parse formula into ready-to-use-arrays
|
6
|
+
# 2. use those arrays to perform calculations
|
7
|
+
#
|
8
|
+
#
|
9
|
+
# = Parsing formula
|
10
|
+
# my_formula = Formula.new("100% – (MAX(score – 5, 0) * 10%)") => Formula
|
11
|
+
# my_formula_data = Formula.make("100% – (MAX(score – 5, 0) * 10%)") => Array
|
12
|
+
#
|
13
|
+
# The array format used for formula data is [:operator, [parameter, parameter]]
|
14
|
+
# the parameters can also be arrays: e.g. sub-calculations
|
15
|
+
#
|
16
|
+
# The text formula can be build with the following elements:
|
17
|
+
# == operators:
|
18
|
+
# -:: subtract. subtracts the right side from the left side argument.
|
19
|
+
# *:: multiply. multiplies the left side with the right side argument.
|
20
|
+
# /:: divide. divides the left side with the ride side argument.
|
21
|
+
# +:: add. adds the right side to the left side argument.
|
22
|
+
# ^:: power. multiplies the left side by the power of the right side.
|
23
|
+
#
|
24
|
+
# == functions:
|
25
|
+
# functions have the format of name(parameters)
|
26
|
+
# the parameters of the function will be pre calculated before the code of the function is executed.
|
27
|
+
# supported functions:
|
28
|
+
#
|
29
|
+
# max:: selects the biggest value from the provided values
|
30
|
+
# min:: selects the smallest value from the provided values
|
31
|
+
# sum:: creates a sum of all the provided values
|
32
|
+
# avg:: creates an average of all the provided values
|
33
|
+
# select:: selects the value with the index of the first parameter
|
34
|
+
# empty:: returns 1 if the given string is empty, 0 otherwise
|
35
|
+
#
|
36
|
+
# == parenthesis:
|
37
|
+
# parentesis can be used to group calculation parts
|
38
|
+
#
|
39
|
+
# == variables:
|
40
|
+
# terms that start with a alfabetic character and contain only alfanumeric characters and underscores
|
41
|
+
# can be used as variables. A hash with variables should be supplied when the calculation is performed
|
42
|
+
#
|
43
|
+
# == numeric values:
|
44
|
+
# numeric values like integers, floats and percentages are also allowed. Percentages will be converted to floats.
|
45
|
+
# 3% and 66% will be converted to resp. 100% / 3 and 200% / 3
|
46
|
+
#
|
47
|
+
# = Performing calculations
|
48
|
+
# my_formula.call(:score => 7.0) => 0.8 (using the above formula example)
|
49
|
+
# Formula.calculate(my_formula_data, :score => 3.0) => 1.0 (using the above formula example)
|
50
|
+
#
|
51
|
+
class Reporter::Formula
|
52
|
+
|
53
|
+
# Known operators
|
54
|
+
OPERATORS = "-*/+^"
|
55
|
+
|
56
|
+
# parse the given code formula in an array using the format
|
57
|
+
# calculation = [operation, [parameter, parameter]]
|
58
|
+
# a parameter can ofcourse be in turn another calculation
|
59
|
+
def initialize(code)
|
60
|
+
@calculation = Reporter::Formula.make code
|
61
|
+
#puts "#{@calculation.inspect}"
|
62
|
+
end
|
63
|
+
|
64
|
+
attr_reader :calculation
|
65
|
+
|
66
|
+
# Parses the given formula as text and returns the formula in nested array form.
|
67
|
+
def self.make(code)
|
68
|
+
#puts "parsing: #{code}"
|
69
|
+
begin
|
70
|
+
parse_operation(code)
|
71
|
+
rescue StandardError => e
|
72
|
+
puts "Error in formula: #{code}: #{e}"
|
73
|
+
raise
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# executes the formula with a hash of given calculation terms
|
78
|
+
def call(input)
|
79
|
+
begin
|
80
|
+
Reporter::Formula.calculate(@calculation, input)
|
81
|
+
rescue StandardError => e
|
82
|
+
Rails.logger.error "Error executing formula: #{Reporter::Formula.calculation_to_s(@calculation, input)} : #{e.message}"
|
83
|
+
raise
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def term_list
|
88
|
+
terms = {}
|
89
|
+
Reporter::Formula.term_list @calculation, terms
|
90
|
+
terms.keys
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_string(input)
|
94
|
+
Reporter::Formula.calculation_to_s(@calculation, input)
|
95
|
+
end
|
96
|
+
|
97
|
+
def solve(input)
|
98
|
+
Reporter::Formula.calculation_to_s(@calculation, input, true)
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.calculation_to_s(calculation, input, solve = false)
|
102
|
+
operation, parameters = * calculation
|
103
|
+
|
104
|
+
string_parameters = parameters.collect do |parameter|
|
105
|
+
parameter.is_a?(Array) ? "#{calculation_to_s(parameter, input, solve)}" : parameter
|
106
|
+
end
|
107
|
+
case operation
|
108
|
+
when :add,
|
109
|
+
:subtract,
|
110
|
+
:times,
|
111
|
+
:divide,
|
112
|
+
:power then
|
113
|
+
"(#{string_parameters[0]} #{{:add => "+",
|
114
|
+
:subtract => "-",
|
115
|
+
:times => "*",
|
116
|
+
:divide => "/",
|
117
|
+
:power => "^"}[operation]} #{string_parameters[1]})"
|
118
|
+
# functions:
|
119
|
+
when :max,
|
120
|
+
:min,
|
121
|
+
:sum,
|
122
|
+
:select,
|
123
|
+
:avg,
|
124
|
+
:empty then
|
125
|
+
if solve
|
126
|
+
result = calculate(calculation, input)
|
127
|
+
"#{operation}(#{string_parameters * ","})[#{result}]"
|
128
|
+
else
|
129
|
+
"#{operation}(#{string_parameters * ","})"
|
130
|
+
end
|
131
|
+
# variables
|
132
|
+
when :text then
|
133
|
+
"\"#{string_parameters[0]}\""
|
134
|
+
when :term then
|
135
|
+
"#{string_parameters[0]}[#{input[string_parameters[0]] ? input[string_parameters[0]] : "nil"}]"
|
136
|
+
when :negative_term then
|
137
|
+
"-#{string_parameters[0]}[#{input[string_parameters[0]] ? input[string_parameters[0]] : "nil"}]"
|
138
|
+
when :literal then
|
139
|
+
begin
|
140
|
+
"nil" if string_parameters[0].nil?
|
141
|
+
end
|
142
|
+
# no-op
|
143
|
+
when nil then
|
144
|
+
string_parameters[0].to_s
|
145
|
+
when :percentage then
|
146
|
+
"#{string_parameters[0] * 100.0}%"
|
147
|
+
else
|
148
|
+
"!unsupported(#{operation}}"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def self.term_list(calculation, input = {})
|
153
|
+
operation, parameters = *calculation
|
154
|
+
|
155
|
+
parameters = parameters.collect do |parameter|
|
156
|
+
parameter.is_a?(Array) ? term_list(parameter, input) : parameter
|
157
|
+
end
|
158
|
+
|
159
|
+
case operation
|
160
|
+
# variables
|
161
|
+
when :term then
|
162
|
+
input[parameters[0]] = :term
|
163
|
+
when :negative_term then
|
164
|
+
input[parameters[0]] = :term
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.calculate(calculation, input)
|
169
|
+
operation, parameters = *calculation
|
170
|
+
|
171
|
+
parameters = parameters.collect do |parameter|
|
172
|
+
parameter.is_a?(Array) ? calculate(parameter, input) : parameter
|
173
|
+
end
|
174
|
+
|
175
|
+
return nil if (parameters[0].nil? or parameters[1].nil?) and [:add, :subtract, :times, :divide, :power].include? operation
|
176
|
+
|
177
|
+
case operation
|
178
|
+
when :add then
|
179
|
+
parameters[0] + parameters[1]
|
180
|
+
when :subtract then
|
181
|
+
parameters[0] - parameters[1]
|
182
|
+
when :times then
|
183
|
+
parameters[0] * parameters[1]
|
184
|
+
when :divide then
|
185
|
+
parameters[1] == 0 ? nil : parameters[0].to_f / parameters[1].to_f
|
186
|
+
when :power then
|
187
|
+
parameters[0] ** parameters[1]
|
188
|
+
# functions:
|
189
|
+
when :max then
|
190
|
+
parameters.compact.max
|
191
|
+
when :min then
|
192
|
+
parameters.compact.min
|
193
|
+
when :sum then
|
194
|
+
begin
|
195
|
+
result = 0.0
|
196
|
+
parameters.each { |value| result += value || 0.0 }
|
197
|
+
result
|
198
|
+
end
|
199
|
+
when :select then
|
200
|
+
begin
|
201
|
+
index = parameters.shift
|
202
|
+
index.is_a?(Numeric) ? parameters[index - 1] : nil
|
203
|
+
end
|
204
|
+
when :avg then
|
205
|
+
begin
|
206
|
+
items = parameters.compact
|
207
|
+
result = 0.0
|
208
|
+
items.each { |value| result += value }
|
209
|
+
result / items.length
|
210
|
+
end
|
211
|
+
when :empty then
|
212
|
+
begin
|
213
|
+
result = parameters.collect { |item| item.to_s.strip == "" ? 1 : 0 }
|
214
|
+
result.include?(0) ? 0 : 1
|
215
|
+
end
|
216
|
+
# variables
|
217
|
+
when :term then
|
218
|
+
begin
|
219
|
+
raise "Can't find term: #{parameters[0]}. Has keys: #{input.keys.collect(&:to_s).sort.inspect}" unless input.has_key? parameters[0]
|
220
|
+
input[parameters[0]]
|
221
|
+
end
|
222
|
+
when :negative_term then
|
223
|
+
begin
|
224
|
+
raise "Can't find term: #{parameters[0]}. Has keys: #{input.keys.sort.inspect}" unless input.has_key? parameters[0]
|
225
|
+
val = input[parameters[0]]
|
226
|
+
return nil unless val
|
227
|
+
- val
|
228
|
+
end
|
229
|
+
when :literal
|
230
|
+
parameters[0]
|
231
|
+
when :text
|
232
|
+
parameters[0]
|
233
|
+
# no-op
|
234
|
+
when nil, :percentage then
|
235
|
+
parameters[0].to_f
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
private
|
240
|
+
|
241
|
+
def self.parse_operation(code)
|
242
|
+
#puts "parsing: #{code}"
|
243
|
+
|
244
|
+
# check if the code is totally surrounded by parenthesis that can be removed. remove them if possible
|
245
|
+
code = ungroup code
|
246
|
+
|
247
|
+
left, right, operator, operator_char = "", "", nil, ""
|
248
|
+
char_index, group_level, in_text = 0, 0, false
|
249
|
+
while char_index < code.length
|
250
|
+
char = code[char_index, 1]
|
251
|
+
if operator.nil? and OPERATORS.include? char and group_level == 0 and not in_text
|
252
|
+
operator = case char
|
253
|
+
when "-" then :subtract
|
254
|
+
when "+" then :add
|
255
|
+
when "*" then :times
|
256
|
+
when "/" then :divide
|
257
|
+
when "^" then :power
|
258
|
+
end
|
259
|
+
operator_char = char
|
260
|
+
else
|
261
|
+
in_text = !in_text if char == "\""
|
262
|
+
group_level += (char == "(") ? 1 : -1 if "()".include? char and not in_text
|
263
|
+
operator ? right += char : left += char
|
264
|
+
end
|
265
|
+
char_index += 1
|
266
|
+
end
|
267
|
+
begin
|
268
|
+
#puts "parse-result: #{operator}, #{left}, #{right}"
|
269
|
+
|
270
|
+
return parse_definition(left.strip) unless operator
|
271
|
+
return parse_definition(operator_char + right) if operator and left.strip == ""
|
272
|
+
return operator, [parse_operation(left.strip), parse_operation(right.strip)]
|
273
|
+
rescue StandardError => e
|
274
|
+
puts "can't parse code: \"#{code}\""
|
275
|
+
raise
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def self.parse_definition(code)
|
280
|
+
code = code.strip
|
281
|
+
|
282
|
+
# text "some text"
|
283
|
+
if result = code.match(/\A"([^"]*)"\z/)
|
284
|
+
return :text, result[1]
|
285
|
+
|
286
|
+
# parse percentages 100%, 10%
|
287
|
+
elsif result = code.match(/\A([\d\.]+)%\z/)
|
288
|
+
return :percentage, [1.0 / 3.0] if result[1].to_f == 33.0
|
289
|
+
return :percentage, [2.0 / 3.0] if result[1].to_f == 66.0
|
290
|
+
return :percentage, [result[1].to_f / 100.0]
|
291
|
+
|
292
|
+
# parse function calls in the format FUNCTION(parameters)
|
293
|
+
elsif result = code.upcase.match(/\A([A-Z_]+)\((.+)\)\z/m)
|
294
|
+
return result[1].downcase.to_sym, self.parameterize(result[2][0 .. -1]).collect { |parameter| parse_operation(parameter) }
|
295
|
+
|
296
|
+
# parse numeric value
|
297
|
+
elsif code.to_i.to_s == code
|
298
|
+
return nil, [code.to_i]
|
299
|
+
|
300
|
+
# parse numeric value
|
301
|
+
elsif code.to_f.to_s == code
|
302
|
+
return nil, [code.to_f]
|
303
|
+
|
304
|
+
# parse literal
|
305
|
+
elsif result = code.upcase.match(/\ANIL\z/)
|
306
|
+
return :literal, [nil]
|
307
|
+
|
308
|
+
# parse variable term
|
309
|
+
elsif result = code.upcase.match(/\A([A-Z][A-Z0-9_]*)\z/)
|
310
|
+
return :term, [result[1].downcase.to_sym]
|
311
|
+
elsif result = code.upcase.match(/\A-([A-Z][A-Z0-9_]*)\z/)
|
312
|
+
return :negative_term, [result[1].downcase.to_sym]
|
313
|
+
else
|
314
|
+
raise "can't parse code: \"#{code}\""
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# check if the code is totally surrounded by parenthesis that can be removed. remove them if possible
|
319
|
+
# examples:
|
320
|
+
# ungroup("(my code ()") => "(my code ()"
|
321
|
+
# ungroup("(my code ())") => "my code ()"
|
322
|
+
# ungroup("(my code) ()") => "(my code) ()"
|
323
|
+
# ungroup("m(my code)") => "m(my code)"
|
324
|
+
def self.ungroup(code)
|
325
|
+
# exit if the code does not start with an opening parentesis
|
326
|
+
return code unless code[0, 1] == "("
|
327
|
+
return code unless code[-1, 1] == ")"
|
328
|
+
# since we know the first character is an opening parenthesis,
|
329
|
+
# start parsing at the second character, and assume grouping level 1
|
330
|
+
group_level, char_index, in_text = 1, 1, false
|
331
|
+
while char_index < code.length
|
332
|
+
char = code[char_index, 1]
|
333
|
+
in_text = !in_text if char == "\""
|
334
|
+
group_level += 1 if char == "(" and not in_text
|
335
|
+
group_level -= 1 if char == ")" and not in_text
|
336
|
+
|
337
|
+
# only strip the first and last parenthesis if we exit the grouping AND we reached the last character
|
338
|
+
return code[1 .. -2] if group_level == 0 and char_index == code.length - 1
|
339
|
+
char_index += 1
|
340
|
+
end
|
341
|
+
code
|
342
|
+
end
|
343
|
+
|
344
|
+
#
|
345
|
+
# SUM(1, 2), SELECT(1, 2, 3)
|
346
|
+
def self.parameterize(code)
|
347
|
+
result = []
|
348
|
+
# since we know the first character is an opening parenthesis,
|
349
|
+
# start parsing at the second character, and assume grouping level 1
|
350
|
+
current_param, char = "", ""
|
351
|
+
group_level, char_index, in_text = 0, 0, false
|
352
|
+
while char_index <= code.length
|
353
|
+
char = code[char_index, 1]
|
354
|
+
in_text = !in_text if char == "\""
|
355
|
+
group_level += 1 if char == "(" and not in_text
|
356
|
+
group_level -= 1 if char == ")" and not in_text
|
357
|
+
|
358
|
+
if char == "," and group_level == 0 and not in_text
|
359
|
+
result << current_param
|
360
|
+
current_param = ""
|
361
|
+
else
|
362
|
+
current_param << char
|
363
|
+
end
|
364
|
+
|
365
|
+
char_index += 1
|
366
|
+
end
|
367
|
+
result << current_param unless current_param == ""
|
368
|
+
result
|
369
|
+
end
|
370
|
+
|
371
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Reporter::ResultRow
|
2
|
+
|
3
|
+
def initialize(data_set, scope_serialization)
|
4
|
+
@data_set = data_set
|
5
|
+
@scope_serialization = scope_serialization
|
6
|
+
@field_cache = { }
|
7
|
+
end
|
8
|
+
|
9
|
+
def [] field
|
10
|
+
field_cache[field] ||= load_field_values(field)[field]
|
11
|
+
end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
attr_reader :data_set, :scope_serialization
|
16
|
+
attr_accessor :field_cache
|
17
|
+
|
18
|
+
def load_field_values(*fields)
|
19
|
+
execute_fields *(fields + [{ :scope => scope_serialization, :row => self }])
|
20
|
+
end
|
21
|
+
|
22
|
+
def execute_fields *fields
|
23
|
+
options = fields.extract_options!
|
24
|
+
temp_scope = data_set.data_source.scopes.current_scope
|
25
|
+
field_options = {}
|
26
|
+
data_set.data_source.scopes.apply_scope options[:scope] if options[:scope]
|
27
|
+
field_options[:row] = options[:row] if options[:row]
|
28
|
+
results = {}
|
29
|
+
fields.each do |field|
|
30
|
+
results[field] = data_set.data_structure.field_value_of field, field_options
|
31
|
+
end
|
32
|
+
data_set.data_source.scopes.apply_scope temp_scope
|
33
|
+
results
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Reporter::Scope::Base
|
2
|
+
|
3
|
+
def initialize scoping, name, data_source, mappings
|
4
|
+
@data_source = data_source
|
5
|
+
@scoping = scoping
|
6
|
+
@name = name
|
7
|
+
@mappings = scoping.normalize_mapping mappings
|
8
|
+
@limit = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def limit= *args
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
|
15
|
+
def change value
|
16
|
+
raise NotImplementedError
|
17
|
+
end
|
18
|
+
|
19
|
+
def value
|
20
|
+
raise NotImplementedError
|
21
|
+
end
|
22
|
+
|
23
|
+
def apply_on source
|
24
|
+
raise NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
def iterate &block
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
|
31
|
+
attr_reader :name, :mappings, :limit
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
attr_reader :scoping, :data_source
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
class Reporter::Scope::DateScope < Reporter::Scope::Base
|
2
|
+
|
3
|
+
def initialize scoping, name, data_source, mappings
|
4
|
+
super scoping, name, data_source, mappings
|
5
|
+
@period = nil
|
6
|
+
end
|
7
|
+
|
8
|
+
def limit= period
|
9
|
+
@limit = period_as_range(period)
|
10
|
+
end
|
11
|
+
|
12
|
+
def value
|
13
|
+
active_period
|
14
|
+
end
|
15
|
+
|
16
|
+
def human_name
|
17
|
+
active_period.human_name
|
18
|
+
end
|
19
|
+
|
20
|
+
def active_period
|
21
|
+
get_period || @limit
|
22
|
+
end
|
23
|
+
|
24
|
+
def change period
|
25
|
+
scoping.serialize_scope name, period_as_range(period)
|
26
|
+
end
|
27
|
+
|
28
|
+
def apply_on source, period = nil
|
29
|
+
raise "No mapped column for source #{source}" unless mappings.has_key? source.name
|
30
|
+
column = mappings[source.name]
|
31
|
+
# The time period range is inclusive in all aspects of the report to detemine correctly if it runs to the end of a year
|
32
|
+
# or the beginning of another year.
|
33
|
+
# In SQL, the period is used as a BETWEEN statement, with the end exclusive.
|
34
|
+
# In code when we want the whole of Januari, we use 1-1 00:00 till 1-31 23:59
|
35
|
+
# in SQL, we need to use BETWEEN 1-1 00:00 AND 2-1 00:00
|
36
|
+
# when a date includes time, we need to add 1 second. If a date has no time, we need to include 1 day.
|
37
|
+
|
38
|
+
period ||= active_period.dup
|
39
|
+
period = if period.end.is_a? Date
|
40
|
+
period.begin .. period.end.advance(:days => 1)
|
41
|
+
else
|
42
|
+
period.begin .. period.end.advance(:seconds => 1)
|
43
|
+
end
|
44
|
+
|
45
|
+
case column
|
46
|
+
when String :
|
47
|
+
source.where(column.to_sym => period)
|
48
|
+
when Hash : begin
|
49
|
+
column.each do |key, value|
|
50
|
+
source = source.joins(key).where(key => { value => period })
|
51
|
+
end
|
52
|
+
source
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def group_on source, period_type
|
58
|
+
column = mappings[source.name]
|
59
|
+
case column
|
60
|
+
when String :
|
61
|
+
"#{period_type.to_s.upcase}(#{column})"
|
62
|
+
when Hash : begin
|
63
|
+
column.each do |key, value|
|
64
|
+
table_name = table_name_of_association source, key
|
65
|
+
return "#{period_type.to_s.upcase}(#{table_name}.#{value})"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.possible_scopes sources
|
72
|
+
results = []
|
73
|
+
global_dates = nil
|
74
|
+
specific_dates = { :type => :date, :match => :loose }
|
75
|
+
sources.each do |source|
|
76
|
+
global_dates = global_dates ? (global_dates & source.date_columns) : source.date_columns
|
77
|
+
specific_dates[source.model_name] = source.date_columns
|
78
|
+
end
|
79
|
+
results << specific_dates
|
80
|
+
global_dates.each do |reference|
|
81
|
+
result_hash = {:type => :date, :match => :exact}
|
82
|
+
sources.each { |source| result_hash[source.model_name] = reference }
|
83
|
+
results << result_hash
|
84
|
+
end if global_dates
|
85
|
+
results
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def get_period
|
91
|
+
scoping.unserialize_scope name
|
92
|
+
end
|
93
|
+
|
94
|
+
def period_as_range period
|
95
|
+
case period
|
96
|
+
when Range :
|
97
|
+
period.dup
|
98
|
+
when Fixnum :
|
99
|
+
Date.civil(period).beginning_of_year .. Date.civil(period).end_of_year
|
100
|
+
when :year_cumulative :
|
101
|
+
active_period.begin.beginning_of_year .. active_period.end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def table_name_of_association source, name
|
106
|
+
source.reflect_on_association(name.to_sym).klass.table_name
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
class Reporter::Scope::ReferenceScope < Reporter::Scope::Base
|
2
|
+
|
3
|
+
def initialize scoping, name, data_source, *args
|
4
|
+
mappings = args.extract_options!
|
5
|
+
mappings = create_mappings_for_object_type(data_source, args.first, mappings) if args.first
|
6
|
+
super scoping, data_source, name, mappings
|
7
|
+
@limiter = nil
|
8
|
+
@id_collection = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def limit= object_or_array
|
12
|
+
@limiter = object_or_array
|
13
|
+
end
|
14
|
+
|
15
|
+
def group_object
|
16
|
+
scoping.unserialize_scope(name)
|
17
|
+
end
|
18
|
+
|
19
|
+
def value
|
20
|
+
group_object || @limiter
|
21
|
+
end
|
22
|
+
|
23
|
+
def change object
|
24
|
+
scoping.serialize_scope name, item
|
25
|
+
end
|
26
|
+
|
27
|
+
def human_name
|
28
|
+
limiter = group_object || @limiter
|
29
|
+
return limiter.collect { |item| human_name_for item }.to_sentence if limiter.is_a? Enumerable
|
30
|
+
human_name_for limiter
|
31
|
+
end
|
32
|
+
|
33
|
+
def apply_on source
|
34
|
+
raise "No mapped column for source #{source}" unless mappings.has_key? source.name
|
35
|
+
reference = mappings[source.name]
|
36
|
+
|
37
|
+
limiter = group_object || @limiter
|
38
|
+
if limiter
|
39
|
+
q, values = limit_through_association source, reference, limiter
|
40
|
+
source.where(q, values)
|
41
|
+
else
|
42
|
+
source
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def iterate items, data_set, &block
|
47
|
+
items ||= @limiter
|
48
|
+
if items.is_a? Enumerable
|
49
|
+
items.each do |item|
|
50
|
+
scoping.serialize_scope name, item
|
51
|
+
yield data_set.get_row
|
52
|
+
end
|
53
|
+
scoping.serialize_scope name, nil
|
54
|
+
else
|
55
|
+
scoping.serialize_scope name, items
|
56
|
+
yield data_set.get_row
|
57
|
+
scoping.serialize_scope name, nil
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.possible_scopes sources
|
62
|
+
results = []
|
63
|
+
# check reflections
|
64
|
+
reflection_references = nil
|
65
|
+
sources.each do |source|
|
66
|
+
relations = source.relations_to_objects
|
67
|
+
relation_pool = relations.keys + [source.active_record]
|
68
|
+
reflection_references = reflection_references ? (reflection_references & relation_pool) : relation_pool
|
69
|
+
end
|
70
|
+
(reflection_references || []).each do |reflection_object|
|
71
|
+
result_hash = { :type => :reference, :match => :loose, :object => reflection_object }
|
72
|
+
sources.each do |source|
|
73
|
+
fields = []
|
74
|
+
fields << "id" if (source.active_record == reflection_object)
|
75
|
+
fields += source.relations_to_objects[reflection_object] || []
|
76
|
+
result_hash[source.model_name] = fields
|
77
|
+
end
|
78
|
+
results << result_hash
|
79
|
+
end
|
80
|
+
#Rails.logger.info reflection_references.inspect
|
81
|
+
|
82
|
+
results
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def limit_through_association source, reference, limiter
|
88
|
+
if reference.is_a? Array
|
89
|
+
query = [[], {}]
|
90
|
+
reference.each do |ref|
|
91
|
+
q, values = limit_through_association source, ref, limiter
|
92
|
+
query[0] << q
|
93
|
+
query[1].merge! values
|
94
|
+
end
|
95
|
+
return "(#{query[0].join ") OR ("})", query[1]
|
96
|
+
end
|
97
|
+
association = source.reflect_on_association(reference.to_sym)
|
98
|
+
if association.macro == :belongs_to
|
99
|
+
id_collection = get_ids_from limiter, reference, association
|
100
|
+
#Rails.logger.info "Belongs to association #{reference} limited by #{limiter}: #{id_collection.inspect}"
|
101
|
+
return "#{reference}_id IN(:#{reference}_ids)", { "#{reference}_ids".to_sym => id_collection }
|
102
|
+
elsif association.macro == :has_one and association.options[:through]
|
103
|
+
#Rails.logger.info "Has one through association #{reference} limited by #{limiter}"
|
104
|
+
through_association = association.options[:through]
|
105
|
+
limit_through_association source, through_association, limiter
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def get_ids_from item, reference, association
|
110
|
+
return [] if item.nil?
|
111
|
+
return item.collect { |sub_item| get_ids_from sub_item, reference, association }.flatten if item.is_a? Enumerable
|
112
|
+
if item.class.ancestors.include? ActiveRecord::Base
|
113
|
+
|
114
|
+
return [item.id] if item.class == association.klass
|
115
|
+
return item.send("#{reference.to_s}_ids".to_sym) if item.respond_to? "#{reference.to_s}_ids"
|
116
|
+
return [item.send("#{reference.to_s}_id".to_sym)] if item.respond_to? "#{reference.to_s}_id"
|
117
|
+
end
|
118
|
+
[]
|
119
|
+
end
|
120
|
+
|
121
|
+
def human_name_for item
|
122
|
+
item.name
|
123
|
+
end
|
124
|
+
|
125
|
+
def create_mappings_for_object_type data_source, object, mappings
|
126
|
+
possible = self.class.possible_scopes data_source.sources
|
127
|
+
#Rails.logger.info possible.inspect
|
128
|
+
possible.each do |reference_mapping|
|
129
|
+
return create_mapping_from(reference_mapping, mappings, data_source.sources) if reference_mapping[:object] == object
|
130
|
+
end
|
131
|
+
raise "No valid data-source mapping could be made with #{object.name}"
|
132
|
+
end
|
133
|
+
|
134
|
+
def create_mapping_from reference_mapping, mapping_specifics, sources
|
135
|
+
mapping = {}
|
136
|
+
sources.each do |source|
|
137
|
+
key = source.model_name.underscore.to_sym
|
138
|
+
columns = reference_mapping[source.model_name]
|
139
|
+
if columns.size == 1
|
140
|
+
mapping[key] = columns.first
|
141
|
+
else
|
142
|
+
specifics = [mapping_specifics[key]].flatten
|
143
|
+
specifics.each do |specific|
|
144
|
+
raise "No available reference to satisfy one of these columns (#{columns.to_sentence}) for datasource #{source.model_name}" unless specific and columns.include? specific
|
145
|
+
end
|
146
|
+
mapping[key] = mapping_specifics[key]
|
147
|
+
end
|
148
|
+
end
|
149
|
+
#{ :funeral => "work_area", :cbs_statistic => "work_area" }
|
150
|
+
#Rails.logger.info mapping.inspect
|
151
|
+
mapping
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|