reporter 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/Gemfile
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
Copyright (c) 2010 Matthijs Groen
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
data/README.markdown
ADDED
@@ -0,0 +1,310 @@
|
|
1
|
+
Reporter
|
2
|
+
========
|
3
|
+
Reporter is a small Ruby / Rails 3.0 library for building reports. It is in heavy development right now, so documentation is sparse and code could change drastically from day to day.
|
4
|
+
|
5
|
+
Goal
|
6
|
+
====
|
7
|
+
The goal is to create an easy to use reporting tool that not the programmer, but the client can build their own reports using data from the system.
|
8
|
+
|
9
|
+
1. The client gets a rich AJAX web-interface to build their report data together, apply markup and store the report as a template
|
10
|
+
2. The client gets to see up to date reporting information when they open the template, even with filter and navigation options
|
11
|
+
|
12
|
+
Design
|
13
|
+
======
|
14
|
+
This project will consist of several different parts. The first part is to collect and query the data.
|
15
|
+
The second part is to display that data in nice views or exports.
|
16
|
+
The third part is a rich web interface so that customers can build their own reports.
|
17
|
+
|
18
|
+
The code
|
19
|
+
========
|
20
|
+
|
21
|
+
Dependencies
|
22
|
+
------------
|
23
|
+
As of now the code is dependent on ActiveRecord from Rails 3.
|
24
|
+
|
25
|
+
Installation and Use
|
26
|
+
--------------------
|
27
|
+
Since it is in the early development stages, there are no Gem builds available. The tool is not yet for production use, and should only be used in experimental applications. The way I develop it now is pull the project in a folder on your workstation, create a new Rails 3 project and make a symlink folder from the lib/reporter folder to the app/models/reporter folder. This is for code reloading between requests. The goal is to have a Gem in a later stadium.
|
28
|
+
|
29
|
+
Examples
|
30
|
+
========
|
31
|
+
This code is done in the controller
|
32
|
+
|
33
|
+
# 1. Build the dataset
|
34
|
+
market_share_report = Reporter::DataSet.new do |r|
|
35
|
+
r.data_source = Reporter::DataSource.new do |data_set|
|
36
|
+
|
37
|
+
# 1a. Add the sources to extract data from
|
38
|
+
data_set << Sale << MarketshareStatistic
|
39
|
+
#Rails.logger.info data_set.scopes.possible.inspect # You can get a list of possible links between the given models
|
40
|
+
|
41
|
+
# 1b. Link the sources together on common properties
|
42
|
+
data_set.scopes.add_date_scope :time, :sale => "date", :marketshare_statistic => "date"
|
43
|
+
data_set.scopes.add_reference_scope :area, Area
|
44
|
+
end
|
45
|
+
|
46
|
+
# 1c. Add the fields / values / calculations to extract from the datasources
|
47
|
+
r.data_structure do |row|
|
48
|
+
row.add_field :period, :time
|
49
|
+
row.add_count_field :sales, :sales
|
50
|
+
row.add_sum_field :sales_workarea, :marketshare_statistics, "amount_sold"
|
51
|
+
row.add_formula_field :area_marketshare, "sales / sales_workarea"
|
52
|
+
row.add_sum_field :sales_national, :marketshare_statistics, "amount_sold", :ignore_scope => :area, :conditions => { :area_id => nil }
|
53
|
+
row.add_formula_field :national_marketshare, "sales / sales_national"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# 2. set master scopes
|
58
|
+
market_share_report.data_source.scopes.limit_scope :time, 2010 # same as Date.civil(2010).beginning_of_year .. Date.civil(2010).end_of_year
|
59
|
+
market_share_report.data_source.scopes.limit_scope :area, Company.first
|
60
|
+
|
61
|
+
@market_share = market_share # expose report for view
|
62
|
+
|
63
|
+
This code is for the view (HAML example)
|
64
|
+
|
65
|
+
%h1
|
66
|
+
Marketshare report for
|
67
|
+
= @market_share_report.scope_name :area
|
68
|
+
= @market_share_report.scope_name :time
|
69
|
+
%table.report
|
70
|
+
%thead
|
71
|
+
%th Month
|
72
|
+
%th Sales
|
73
|
+
%th Sales in area
|
74
|
+
%th Marketshare in area
|
75
|
+
%th National sales
|
76
|
+
%th National marktetshare
|
77
|
+
%tbody
|
78
|
+
- @market_share_report.iterate_time :time, :month, :quarter, :year do |row|
|
79
|
+
%tr
|
80
|
+
%td= row[:period] # The query will fire at this point. so caching makes huge profit!
|
81
|
+
%td= row[:funerals]
|
82
|
+
%td= row[:deaths_workarea]
|
83
|
+
%td= row[:workarea_marketshare].as_percentage
|
84
|
+
%td= row[:deaths_national]
|
85
|
+
%td= row[:national_marketshare].as_percentage
|
86
|
+
|
87
|
+
In detail: Building the dataset
|
88
|
+
===============================
|
89
|
+
|
90
|
+
# 1. Build the dataset
|
91
|
+
market_share_report = Reporter::DataSet.new do |r|
|
92
|
+
r.data_source = Reporter::DataSource.new do |data_set|
|
93
|
+
|
94
|
+
# 1a. Add the sources to extract data from
|
95
|
+
data_set << Sale << MarketshareStatistic
|
96
|
+
#Rails.logger.info data_set.scopes.possible.inspect # You can get a list of possible links between the given models
|
97
|
+
|
98
|
+
Data sources and scopes
|
99
|
+
-----------------------
|
100
|
+
ActiveRecord models are added as datasources. The system will try to find common properties to scale the models against. This happens using
|
101
|
+
class methods on Scope classes (DateScope and ReferenceScope).
|
102
|
+
|
103
|
+
DateScope looks for date / time fields to lay the models next to each other.
|
104
|
+
If they find the same columnname on all models (created_at, updated_on) they will indicate an exact match. Otherwise it suggests a loose match. The strictness of the match is not relevant for building and linking the dataset, but an indicator for building a UI.
|
105
|
+
|
106
|
+
ReferenceScope looks for the same type of Object association on all models. if all models have an _belongs_to :area_ method, the ReferenceScope will suggest a link through "area". You can even have a has_one relationship through a belongs to.
|
107
|
+
So:
|
108
|
+
has_one :area, :through => :sale_area
|
109
|
+
is supported.
|
110
|
+
|
111
|
+
# 1b. Link the sources together on common properties
|
112
|
+
data_set.scopes.add_date_scope :time, :sale => "date", :marketshare_statistic => "date"
|
113
|
+
data_set.scopes.add_reference_scope :area, Area
|
114
|
+
end
|
115
|
+
|
116
|
+
After the suggestions (which in code you won't use actively) you have to set the scopes on the properties, and decide wich fields to link together.
|
117
|
+
In the reference example, all models only have one association to the Area object, so no specific columname is needed. (it will figure that out itself)
|
118
|
+
|
119
|
+
We have one big pool of data, wich is filterable through scopes (time and area in this case)
|
120
|
+
Now we have to tell how to extract the data from the set. Calculations are defined here.
|
121
|
+
|
122
|
+
# 1c. Add the fields / values / calculations to extract from the datasources
|
123
|
+
r.data_structure do |row|
|
124
|
+
row.add_field :period, :time
|
125
|
+
row.add_count_field :sales, :sales
|
126
|
+
row.add_sum_field :sales_workarea, :marketshare_statistics, "amount_sold"
|
127
|
+
row.add_formula_field :area_marketshare, "sales / sales_workarea"
|
128
|
+
row.add_sum_field :sales_national, :marketshare_statistics, "amount_sold", :ignore_scope => :area, :conditions => { :area_id => nil }
|
129
|
+
row.add_formula_field :national_marketshare, "sales / sales_national"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
Field types
|
134
|
+
-----------
|
135
|
+
As you can see there are several different field types to add.
|
136
|
+
|
137
|
+
*Field* If passed a regular value, this value is set for the field with the given name.
|
138
|
+
If passed a Symbol, the name of the scope of the symbol will be used as value
|
139
|
+
you can even pass a block an create a custom field with own querie mechanisms or function calls
|
140
|
+
|
141
|
+
*CountField* counts the records of a given datasource (second parameter) the datasource is here used in plural. (don't know if it will stay this way). An optional hash with conditions can be provided.
|
142
|
+
|
143
|
+
*SumField* similar to the CountField, this fieldtype will sum up all results from a column of a given datasource (datasource is param 3, column is param 4)
|
144
|
+
in the line :sales_national you can see the use of :ignore_scope and an additional conditions hash.
|
145
|
+
|
146
|
+
*:ignore_scope* before retrieval of field data the correct scopes will be built for query execution. If certain scopes must be ignored for certain fields, you can use
|
147
|
+
:ignore_scope => :scope_name or :ignore_scopes => [:scope, :scope]
|
148
|
+
|
149
|
+
*FormulaField* the formula field is for simple calculations. variable names used will be retrieved from their active row.
|
150
|
+
|
151
|
+
|
152
|
+
Limits
|
153
|
+
------
|
154
|
+
At last default limits are set for the scopes. This can be overridden (temporarily) by iterators used in views.
|
155
|
+
|
156
|
+
# 2. set master scopes
|
157
|
+
market_share_report.data_source.scopes.limit_scope :time, 2010 # same as Date.civil(2010).beginning_of_year .. Date.civil(2010).end_of_year
|
158
|
+
market_share_report.data_source.scopes.limit_scope :area, Company.first
|
159
|
+
|
160
|
+
@market_share = market_share # expose report for view
|
161
|
+
|
162
|
+
The value passed is interpreted by their respective scope, so 2010 as value for the DateScope will be translated to Date.civil(2010).beginning_of_year .. Date.civil(2010).end_of_year
|
163
|
+
The same is for ReferenceScope. An object (or list of them) passed that are not matches for the required object (Area in this case) will be investigated for a link.
|
164
|
+
In this case, a Company has_many :areas. All the areas of the company will be used as scope.
|
165
|
+
|
166
|
+
|
167
|
+
In detail: Building the view
|
168
|
+
============================
|
169
|
+
|
170
|
+
%h1
|
171
|
+
Marketshare report for
|
172
|
+
= @market_share_report.scope_name :area
|
173
|
+
= @market_share_report.scope_name :time
|
174
|
+
|
175
|
+
Scope name will print a human readable name for the currently active scope. In this case, :area will print the company name, and :time will print "2010"
|
176
|
+
|
177
|
+
%table.report
|
178
|
+
%thead
|
179
|
+
%th Month
|
180
|
+
%th Sales
|
181
|
+
%th Sales in area
|
182
|
+
%th Marketshare in area
|
183
|
+
%th National sales
|
184
|
+
%th National marktetshare
|
185
|
+
%tbody
|
186
|
+
- @market_share_report.iterate_time :time, :month, :quarter, :year do |row|
|
187
|
+
|
188
|
+
Iteration
|
189
|
+
---------
|
190
|
+
Report supports 2 different iterators (for now). A normal iterator that loops through a set of objects (eg. a list of Areas or Companies could be used) or the
|
191
|
+
time iterator. The time iterator must be provided with the name of the DateScope, and additional arguments for the chunks of time to iterate.
|
192
|
+
:month will iterate the limit period in chunks of 1 month, :quarter in chunks of 3 months, :year in chunks of a year.
|
193
|
+
|
194
|
+
The order of the supplied parameters is important. if you supply: :month, :quarter, :year for the first half of 2010, the periods will be as follows:
|
195
|
+
Jan 10, Feb 10, Mar 10, Q1 10, Apr 10, May 10, Jun 10, Q2 10, 2010
|
196
|
+
|
197
|
+
If you supply these parameters in the order of :year, :month, :quarter, the periods will be as followed:
|
198
|
+
2010, Jan 10, Feb 10, Mar 10, Q1 10, Apr 10, May 10, Jun 10, Q2 10
|
199
|
+
|
200
|
+
If you pass :year, :quarter, :month, the quarters will be placed before their containing months.
|
201
|
+
|
202
|
+
not all combinations are valid however. The arguments are parsed into a tree form, described as follows:
|
203
|
+
:total => 6, :year => 5, :quarter => 4, :month => 3, :week => 2, :day => 1
|
204
|
+
|
205
|
+
the outsides of the series must always have the biggest value of the set.
|
206
|
+
valid: [:year(5), :month(3), :quarter(4)] == :year(pre) => :quarter(post) => :month(nil)
|
207
|
+
valid: [:year(5), :month(3), :week(2), :quarter(4)] == :year(pre) => :quarter(post) => :month(pre) => :week(nil)
|
208
|
+
invalid: [:year(5), :month(3), :quarter(4), :week(2)] The childs of year (biggest in initial set) are: [:month(3), :quarter(4), :week(2)] The larges value is not the first or last item, so this set is invalid.
|
209
|
+
|
210
|
+
%tr
|
211
|
+
%td= row[:period] # The query will fire at this point. so caching makes huge profit!
|
212
|
+
%td= row[:funerals]
|
213
|
+
%td= row[:deaths_workarea]
|
214
|
+
%td= row[:workarea_marketshare].as_percentage
|
215
|
+
%td= row[:deaths_national]
|
216
|
+
%td= row[:national_marketshare].as_percentage
|
217
|
+
|
218
|
+
the [] method in the row will execute the scopes and ask the field to calculate the value. the value is cached so multiple uses of the same value will not decrease performance (since field values can also be accessed by formulas). The Result is an ReportValue object that support several formatting options and meta data.
|
219
|
+
|
220
|
+
Advanced example
|
221
|
+
================
|
222
|
+
I will not cover this in detail, but here a far more complex example of a employee capacity report:
|
223
|
+
|
224
|
+
def capacity_report
|
225
|
+
# 1. Build the dataset
|
226
|
+
capacity_report = Reporter::DataSet.new do |r|
|
227
|
+
r.data_source = Reporter::DataSource.new do |data_set|
|
228
|
+
# 1a. Add the sources to extract data from
|
229
|
+
data_set << Funeral << Employee << TimeRegistration
|
230
|
+
Rails.logger.info data_set.scopes.possible.inspect
|
231
|
+
# 1b. Link the sources together on common properties
|
232
|
+
data_set.scopes.add_date_scope :time, :funeral => "notification", :time_registration => "date"
|
233
|
+
data_set.scopes.add_reference_scope :company, Company
|
234
|
+
end
|
235
|
+
|
236
|
+
# 1c. Add the fields / values / calculations to extract from the datasources
|
237
|
+
r.data_structure do |row|
|
238
|
+
row.add_field :period, :time
|
239
|
+
row.add_count_field :funerals, :funerals
|
240
|
+
row.add_formula_field :funeral_hours, "2080 / 108"
|
241
|
+
row.add_formula_field :funeral_time, "funerals * funeral_hours"
|
242
|
+
|
243
|
+
row.add_field :fte do |data_source, options, result_row|
|
244
|
+
active_period = data_source.scopes.get(:time).active_period
|
245
|
+
conditions = ["(funeral_organizer = ? OR funeral_caretaker = ?) AND parttime = ? AND internal = ? AND start_date <= ? AND (end_date >= ? OR end_date IS NULL)",
|
246
|
+
true, true, false, true, active_period.end, active_period.begin]
|
247
|
+
|
248
|
+
db_start = active_period.begin.to_s(:db)
|
249
|
+
db_end = active_period.end.to_s(:db)
|
250
|
+
source = data_source.get(:employees)
|
251
|
+
value = source.sum "datediff(least(ifnull(end_date, '#{db_end}'), '#{db_end}'), " +
|
252
|
+
"greatest(ifnull(start_date, '#{db_start}'), '#{db_start}')) + 1", :ignore_scope => :time,
|
253
|
+
:conditions => conditions
|
254
|
+
result_row.value = value.to_f
|
255
|
+
end
|
256
|
+
row.add_formula_field :internal_hours, "fte * (40 / 7.0)"
|
257
|
+
row.add_sum_field :external_hours, :time_registrations, :hours
|
258
|
+
row.add_formula_field :total_hours, "internal_hours + external_hours"
|
259
|
+
row.add_formula_field :capacity, "funeral_time / total_hours"
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# 2. set master scopes
|
264
|
+
capacity_report.data_source.scopes.limit_scope :time, 2010
|
265
|
+
capacity_report.data_source.scopes.limit_scope :company, Company.all
|
266
|
+
|
267
|
+
@capacity_report = capacity_report
|
268
|
+
end
|
269
|
+
|
270
|
+
|
271
|
+
The view, that also changes the scopes during iteration:
|
272
|
+
|
273
|
+
%h1
|
274
|
+
Capaciteits rapport
|
275
|
+
= @capacity_report.scope_name :time
|
276
|
+
%table.report
|
277
|
+
%thead
|
278
|
+
%th Maand
|
279
|
+
%th Uv
|
280
|
+
%th Uren
|
281
|
+
%th Werkelijk
|
282
|
+
%th Intern
|
283
|
+
%th Extern
|
284
|
+
%th %
|
285
|
+
|
286
|
+
%th Cum. Uv
|
287
|
+
%th Cum. Uren
|
288
|
+
%th Cum. Werkelijk
|
289
|
+
%th Cum. Intern
|
290
|
+
%th Cum. Extern
|
291
|
+
%th %
|
292
|
+
%tbody
|
293
|
+
- @capacity_report.iterate_time :time, :month do |row|
|
294
|
+
%tr
|
295
|
+
%td= row[:period]
|
296
|
+
%td= row[:funerals]
|
297
|
+
%td= row[:funeral_time].round 2
|
298
|
+
%td= row[:total_hours].round 2
|
299
|
+
%td= row[:internal_hours].round 2
|
300
|
+
%td= row[:external_hours].round 2
|
301
|
+
%td= row[:capacity].as_percentage
|
302
|
+
|
303
|
+
- cum_row = @capacity_report.get_row :time => :year_cumulative
|
304
|
+
%td= cum_row[:funerals]
|
305
|
+
%td= cum_row[:funeral_time].round 2
|
306
|
+
%td= cum_row[:total_hours].round 2
|
307
|
+
%td= cum_row[:internal_hours].round 2
|
308
|
+
%td= cum_row[:external_hours].round 2
|
309
|
+
%td= cum_row[:capacity].as_percentage
|
310
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "reporter"
|
8
|
+
gem.summary = %Q{Report builder.}
|
9
|
+
gem.description = %Q{
|
10
|
+
Reporter adds a consistent way to build reports.
|
11
|
+
}
|
12
|
+
gem.email = "matthijs.groen@gmail.com"
|
13
|
+
gem.homepage = "http://github.com/matthijsgroen/reporter"
|
14
|
+
gem.authors = ["Matthijs Groen"]
|
15
|
+
#gem.add_development_dependency "shoulda"
|
16
|
+
gem.add_dependency "activerecord", "~> 3.0.0"
|
17
|
+
gem.add_dependency "activesupport", "~> 3.0.0"
|
18
|
+
gem.add_dependency "arel", "~> 1.0.1"
|
19
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
20
|
+
end
|
21
|
+
Jeweler::GemcutterTasks.new
|
22
|
+
rescue LoadError
|
23
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
24
|
+
end
|
25
|
+
|
26
|
+
require 'rake/testtask'
|
27
|
+
Rake::TestTask.new(:test) do |test|
|
28
|
+
test.libs << 'lib' << 'test'
|
29
|
+
test.libs << 'vendor/rails/activerecord/lib'
|
30
|
+
test.libs << 'vendor/rails/activesupport/lib'
|
31
|
+
test.libs << 'vendor/arel/lib'
|
32
|
+
test.pattern = 'test/**/test_*.rb'
|
33
|
+
test.verbose = true
|
34
|
+
end
|
35
|
+
|
36
|
+
begin
|
37
|
+
require 'rcov/rcovtask'
|
38
|
+
Rcov::RcovTask.new do |test|
|
39
|
+
test.libs << 'test'
|
40
|
+
test.pattern = 'test/**/test_*.rb'
|
41
|
+
test.verbose = true
|
42
|
+
end
|
43
|
+
rescue LoadError
|
44
|
+
task :rcov do
|
45
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Don't check dependencies since we're testing with vendored libraries
|
50
|
+
# task :test => :check_dependencies
|
51
|
+
|
52
|
+
task :default => :test
|
53
|
+
|
54
|
+
require 'rake/rdoctask'
|
55
|
+
Rake::RDocTask.new do |rdoc|
|
56
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
57
|
+
|
58
|
+
rdoc.rdoc_dir = 'rdoc'
|
59
|
+
rdoc.title = "meta_where #{version}"
|
60
|
+
rdoc.rdoc_files.include('README*')
|
61
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
62
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.1
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require "reporter/support/time_range"
|
2
|
+
|
3
|
+
# DataSet is where all information about the data for the report comes together.
|
4
|
+
#
|
5
|
+
class Reporter::DataSet
|
6
|
+
|
7
|
+
def initialize *args
|
8
|
+
@row_structure = nil
|
9
|
+
@axis_values = {}
|
10
|
+
|
11
|
+
@row_cache = {}
|
12
|
+
yield self if block_given?
|
13
|
+
end
|
14
|
+
|
15
|
+
def data_source= value
|
16
|
+
#TODO Maybe add checks, maybe make the datasource an internal part of the reporting dataset
|
17
|
+
@data_source = value
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :data_source
|
21
|
+
|
22
|
+
# creation of the definition of the available fields/values to retrieve from the various datasources
|
23
|
+
def data_structure *args, &block
|
24
|
+
@data_structure ||= Reporter::DataStructure.new self, *args
|
25
|
+
yield @data_structure if block_given?
|
26
|
+
@data_structure
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_row options = {}
|
30
|
+
# The datastructure of a resultrow it is a container for caching result values so that
|
31
|
+
# formula's can retrieve values to perform calculations. Queries are executed as late as
|
32
|
+
# possible. This way template caching can eliminate performing database queries altogether.
|
33
|
+
current_scope = data_source.scopes.change(options).current_scope
|
34
|
+
@row_cache[current_scope.hash] ||= Reporter::ResultRow.new(self, current_scope)
|
35
|
+
end
|
36
|
+
|
37
|
+
# iterate the chosen scope over a list of selected items. If no items are provided the defined
|
38
|
+
# maximum and minimum limit is used. (scope.set_limit)
|
39
|
+
# use iterate_time to iterate over time periods.
|
40
|
+
def iterate scope, items = nil, &block
|
41
|
+
raise "No data-source set" unless data_source
|
42
|
+
data_source.scopes.get(scope).iterate items, self, &block
|
43
|
+
end
|
44
|
+
|
45
|
+
# returns the name of the current active scope. This can be used to decorate report data with the proper context
|
46
|
+
def scope_name scope
|
47
|
+
raise "No data-source set" unless data_source
|
48
|
+
data_source.scopes.get(scope).human_name
|
49
|
+
end
|
50
|
+
|
51
|
+
include Reporter::TimeIterator
|
52
|
+
|
53
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# ActiveRecordSource supplies information about the datasources and executes the queries upon it.
|
2
|
+
# The source holds the active record model to build queries upon.
|
3
|
+
#
|
4
|
+
class Reporter::DataSource::ActiveRecordSource
|
5
|
+
|
6
|
+
def initialize(data_source, active_record)
|
7
|
+
@data_source = data_source
|
8
|
+
@active_record = active_record
|
9
|
+
@name = active_record.name.pluralize.underscore.to_sym
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :active_record, :name
|
13
|
+
|
14
|
+
# returns all date columns
|
15
|
+
def date_columns
|
16
|
+
load_columns if @date_columns.nil?
|
17
|
+
@date_columns
|
18
|
+
end
|
19
|
+
|
20
|
+
# returns all associations, grouped by the type of model linked to.
|
21
|
+
def relations_to_objects
|
22
|
+
load_columns if @object_links.nil?
|
23
|
+
@object_links
|
24
|
+
end
|
25
|
+
|
26
|
+
# display and inspection
|
27
|
+
def inspect
|
28
|
+
active_record.inspect
|
29
|
+
end
|
30
|
+
|
31
|
+
def model_name
|
32
|
+
active_record.name
|
33
|
+
end
|
34
|
+
|
35
|
+
VALID_CALCULATIONS = %w(count sum average minimum maximum)
|
36
|
+
|
37
|
+
# retrieve data from source
|
38
|
+
def calculate calculation, *args, &block
|
39
|
+
raise "invalid calculation: #{calculation}" unless VALID_CALCULATIONS.include? calculation.to_s
|
40
|
+
# TODO: include check for valid calculations and required parameters
|
41
|
+
options = args.extract_options!.dup
|
42
|
+
scope_options = extract_scope_options_from options
|
43
|
+
source = source_with_applied_scopes(scope_options)
|
44
|
+
source = block.call(source, data_source.scopes) if block_given?
|
45
|
+
source.send *([calculation] + args + [options])
|
46
|
+
end
|
47
|
+
|
48
|
+
# performs a calculation for an entire period, in groups of months, years, weeks, quarters or days
|
49
|
+
def calculate_for_period calculation, period, filter, scope, *args, &block
|
50
|
+
options = args.extract_options!.dup
|
51
|
+
# remove the time scope from the default scopes
|
52
|
+
scope_options = extract_scope_options_from options
|
53
|
+
scope_options[:ignore_scopes] << scope.name.to_sym
|
54
|
+
scope_options[:ignore_scopes].uniq!
|
55
|
+
source = source_with_applied_scopes(scope_options)
|
56
|
+
|
57
|
+
source = block.call(source, data_source.scopes) if block_given?
|
58
|
+
|
59
|
+
# add time scope seperately with full period
|
60
|
+
source = scope.apply_on source, period
|
61
|
+
|
62
|
+
# group on the given filters (year and month, in case of months)
|
63
|
+
grouping = filter.collect { |f| scope.group_on source, f }
|
64
|
+
source = source.group grouping.join(", ")
|
65
|
+
|
66
|
+
# set the columns for the selection. We have to do this in manual SQL, since Rails does not
|
67
|
+
# support multiple group by's in the calculation functions.
|
68
|
+
select = []
|
69
|
+
select << calculation_function(calculation, args)
|
70
|
+
filter.each_with_index { |f, index| select << "#{grouping[index]} as #{f.to_s}" }
|
71
|
+
source = source.select select
|
72
|
+
#Rails.logger.info source.to_sql
|
73
|
+
|
74
|
+
# execute the query and collect the results
|
75
|
+
# place all results in an collection hash with their filter attributes as key
|
76
|
+
result = source.collect do |r|
|
77
|
+
result = r.result.to_f
|
78
|
+
result = result.to_i if result.floor == result
|
79
|
+
res = { :value => result }
|
80
|
+
filter.each { |f| res[f] = r[f.to_s].to_i }
|
81
|
+
res
|
82
|
+
end
|
83
|
+
result
|
84
|
+
end
|
85
|
+
|
86
|
+
# apply all scopes on the active record model
|
87
|
+
def source_with_applied_scopes(options)
|
88
|
+
#Rails.logger.info options.inspect
|
89
|
+
data_source.scopes.apply_on(active_record, options)
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
attr_reader :data_source
|
95
|
+
|
96
|
+
# examine the active record model and store all relevant data for scoping
|
97
|
+
def load_columns
|
98
|
+
@date_columns = []
|
99
|
+
active_record.columns.collect do |column|
|
100
|
+
if [Time, Date].include? column.klass
|
101
|
+
@date_columns << column.name
|
102
|
+
end
|
103
|
+
end.compact
|
104
|
+
|
105
|
+
@object_links = {}
|
106
|
+
active_record.reflect_on_all_associations.each do |reflection|
|
107
|
+
@object_links[reflection.klass] ||= []
|
108
|
+
@object_links[reflection.klass] << reflection.name.to_s
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def extract_scope_options_from options
|
113
|
+
scope_options = {}
|
114
|
+
scope_options[:ignore_scopes] = options.delete(:ignore_scopes) || []
|
115
|
+
scope_options[:ignore_scopes] += [options.delete :ignore_scope] if options[:ignore_scope]
|
116
|
+
scope_options
|
117
|
+
end
|
118
|
+
|
119
|
+
def calculation_function(calculation, args)
|
120
|
+
case calculation
|
121
|
+
when :sum :
|
122
|
+
"SUM(#{args.first}) AS result"
|
123
|
+
when :count :
|
124
|
+
"COUNT(*) AS result"
|
125
|
+
when :average :
|
126
|
+
"AVG(#{args.first}) AS result"
|
127
|
+
when :minimum :
|
128
|
+
"MIN(#{args.first}) AS result"
|
129
|
+
when :maximum :
|
130
|
+
"MAX(#{args.first}) AS result"
|
131
|
+
else
|
132
|
+
raise "Invalid calculation: #{calculation}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|