active_olap 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/MIT-LICENSE +20 -0
- data/README.textile +59 -0
- data/Rakefile +5 -0
- data/active_olap.gemspec +15 -0
- data/init.rb +2 -0
- data/lib/active_olap/aggregate.rb +148 -0
- data/lib/active_olap/category.rb +46 -0
- data/lib/active_olap/configurator.rb +32 -0
- data/lib/active_olap/cube.rb +215 -0
- data/lib/active_olap/dimension.rb +230 -0
- data/lib/active_olap/helpers/chart_helper.rb +99 -0
- data/lib/active_olap/helpers/display_helper.rb +54 -0
- data/lib/active_olap/helpers/form_helper.rb +16 -0
- data/lib/active_olap/helpers/table_helper.rb +92 -0
- data/lib/active_olap/test/assertions.rb +39 -0
- data/lib/active_olap.rb +116 -0
- data/spec/integration/active_olap_spec.rb +1 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/unit/cube_spec.rb +11 -0
- data/tasks/github-gem.rake +312 -0
- data/test/active_olap_test.rb +335 -0
- data/test/helper.rb +115 -0
- data/test/helper_modules_test.rb +65 -0
- metadata +82 -0
@@ -0,0 +1,230 @@
|
|
1
|
+
module ActiveOLAP
|
2
|
+
|
3
|
+
class Dimension
|
4
|
+
|
5
|
+
attr_reader :klass
|
6
|
+
attr_reader :categories
|
7
|
+
attr_reader :category_field
|
8
|
+
attr_reader :info
|
9
|
+
|
10
|
+
attr_reader :joins
|
11
|
+
attr_reader :conditions
|
12
|
+
|
13
|
+
# creates a new Dimension, given a definition.
|
14
|
+
# The definition can be:
|
15
|
+
# - A name (Symbol) of registered definition
|
16
|
+
# - A name (Symbol) of a field in the corresponding table
|
17
|
+
# - A hash, with at most one of the following keys set
|
18
|
+
# - :categories -> for custom categories
|
19
|
+
# - :trend -> for a trend dimension
|
20
|
+
# - :field -> for a table field dimension (similar to passing a Symbol)
|
21
|
+
def self.create(klass, definition = nil)
|
22
|
+
return klass if klass.kind_of? Dimension
|
23
|
+
|
24
|
+
case definition
|
25
|
+
when Dimension
|
26
|
+
return definition
|
27
|
+
when Symbol
|
28
|
+
if klass.active_olap_dimensions.has_key?(definition)
|
29
|
+
if klass.active_olap_dimensions[definition].respond_to?(:call)
|
30
|
+
return Dimension.new(klass, klass.active_olap_dimensions[definition].call)
|
31
|
+
else
|
32
|
+
return Dimension.new(klass, klass.active_olap_dimensions[definition])
|
33
|
+
end
|
34
|
+
else
|
35
|
+
return Dimension.new(klass, definition)
|
36
|
+
end
|
37
|
+
when Array
|
38
|
+
if klass.active_olap_dimensions.has_key?(definition.first)
|
39
|
+
return Dimension.new(klass, klass.active_olap_dimensions[definition.shift].call(*definition))
|
40
|
+
else
|
41
|
+
return Dimension.new(klass, definition)
|
42
|
+
end
|
43
|
+
else
|
44
|
+
return Dimension.new(klass, definition)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Builds a SUM expression for this dimension.
|
49
|
+
def to_count_with_overlap_sql
|
50
|
+
|
51
|
+
count_value = @klass.connection.send(:quote_table_name, @klass.table_name) + '.' +
|
52
|
+
@klass.connection.send(:quote_column_name, :id) # TODO: other column than id
|
53
|
+
|
54
|
+
@categories.map { |category| category.to_count_sql(count_value) }.join(', ')
|
55
|
+
end
|
56
|
+
|
57
|
+
# Builds a CASE expression for this dimension.
|
58
|
+
def to_case_expression(variable_name)
|
59
|
+
if @category_field
|
60
|
+
"#{@klass.connection.send(:quote_column_name, @category_field)} AS #{@klass.connection.send(:quote_column_name, variable_name)}"
|
61
|
+
else
|
62
|
+
whens = @categories.map { |category| @klass.send(:sanitize_sql, ["WHEN (#{category.to_sanitized_sql}) THEN ?", category.label.to_s]) }
|
63
|
+
"CASE #{whens.join(' ')} ELSE NULL END AS #{@klass.connection.send(:quote_column_name, variable_name)}";
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Registers a category in this dimension.
|
68
|
+
# This function is called when a dimension is created and the categories are known or
|
69
|
+
# while the query result is being populated for dimensions with unknown categories.
|
70
|
+
def register_category(cat_label, definition = nil)
|
71
|
+
unless has_category?(cat_label)
|
72
|
+
definition = {:expression => { @category_field => cat_label }} if definition.nil? && @category_field
|
73
|
+
cat = Category.new(self, cat_label, definition)
|
74
|
+
@categories << cat
|
75
|
+
return (@categories.length - 1)
|
76
|
+
else
|
77
|
+
return category_index(cat_label)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Returns a category, given a category label
|
82
|
+
def [](label)
|
83
|
+
category_by_label(label)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns all the category labels
|
87
|
+
def category_labels
|
88
|
+
@categories.map(&:label)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Checks whether this dimension has a category with the provided label
|
92
|
+
def has_category?(label)
|
93
|
+
@categories.any? { |cat| cat.label == label }
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns the index in this dimension of a category identified by its label
|
97
|
+
def category_index(label)
|
98
|
+
@categories.each_with_index { |cat, index| return index if cat.label == label }
|
99
|
+
return nil
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns a category, given its index
|
103
|
+
def category_by_index(index)
|
104
|
+
@categories[index]
|
105
|
+
end
|
106
|
+
|
107
|
+
# Returns a category, given a category label
|
108
|
+
def category_by_label(label)
|
109
|
+
@categories.detect { |cat| cat.label == label }
|
110
|
+
end
|
111
|
+
|
112
|
+
# checks whether thios is a table field dimension which categories are unknown beforehand
|
113
|
+
def is_field_dimension?
|
114
|
+
!@category_field.nil?
|
115
|
+
end
|
116
|
+
|
117
|
+
def is_time_dimension?
|
118
|
+
@info.has_key?(:trend) && @info[:trend] == true
|
119
|
+
end
|
120
|
+
|
121
|
+
def is_custom_dimension?
|
122
|
+
!is_field_dimension? && !is_time_dimension?
|
123
|
+
end
|
124
|
+
|
125
|
+
def has_overlap?
|
126
|
+
@info.has_key?(:overlap) && @info[:overlap] == true
|
127
|
+
end
|
128
|
+
|
129
|
+
# Returns a sanitized SQL expression for a given category
|
130
|
+
def sanitized_sql_for(cat)
|
131
|
+
cat_conditions = is_field_dimension? ? @klass.send(:sanitize_sql, { @category_field => cat }) : self[cat].to_sanitized_sql
|
132
|
+
@klass.send(:merge_conditions, cat_conditions, @conditions)
|
133
|
+
end
|
134
|
+
|
135
|
+
protected
|
136
|
+
|
137
|
+
# Generates an SQL expression for the :other-category
|
138
|
+
def generate_other_condition
|
139
|
+
all_categories = @categories.map { |category| "(#{category.to_sanitized_sql})" }.join(' OR ')
|
140
|
+
"((#{all_categories}) IS NULL OR NOT(#{all_categories}))"
|
141
|
+
end
|
142
|
+
|
143
|
+
# Initializes a new Dimension object. See Dimension#create
|
144
|
+
def initialize(klass, definition)
|
145
|
+
@klass = klass
|
146
|
+
@categories = []
|
147
|
+
|
148
|
+
@info = {}
|
149
|
+
|
150
|
+
@joins = []
|
151
|
+
@conditions = nil
|
152
|
+
|
153
|
+
case definition
|
154
|
+
when Hash
|
155
|
+
hash = definition.clone
|
156
|
+
@conditions = hash.delete(:conditions)
|
157
|
+
@joins += hash[:joins].kind_of?(Array) ? hash.delete(:joins) : [hash.delete(:joins)] if hash.has_key?(:joins)
|
158
|
+
|
159
|
+
if hash.has_key?(:categories)
|
160
|
+
generate_custom_categories(hash.delete(:categories))
|
161
|
+
|
162
|
+
elsif hash.has_key?(:trend)
|
163
|
+
generate_trend_categories(hash.delete(:trend))
|
164
|
+
|
165
|
+
elsif hash.has_key?(:field)
|
166
|
+
generate_field_dimension(hash.delete(:field))
|
167
|
+
|
168
|
+
else
|
169
|
+
raise "Invalid category definition! " + definition.inspect
|
170
|
+
end
|
171
|
+
|
172
|
+
# make the remaining fields available in the info object
|
173
|
+
@info.merge!(hash)
|
174
|
+
|
175
|
+
when Symbol
|
176
|
+
generate_field_dimension(definition)
|
177
|
+
|
178
|
+
else
|
179
|
+
raise "Invalid category definition! " + definition.inspect
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def generate_field_dimension(field)
|
184
|
+
case field
|
185
|
+
when Hash
|
186
|
+
@category_field = field[:column].to_sym
|
187
|
+
else
|
188
|
+
@category_field = field.to_sym
|
189
|
+
end
|
190
|
+
|
191
|
+
unless @klass.column_names.include?(@category_field.to_s)
|
192
|
+
raise "Could not create dimension for unknown field #{@category_field}"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def generate_custom_categories(categories)
|
197
|
+
skip_other = false
|
198
|
+
categories.to_a.each do |category|
|
199
|
+
skip_other = true if category.first == :other
|
200
|
+
register_category(category.first, category.last) if category.last
|
201
|
+
end
|
202
|
+
register_category(:other, :expression => generate_other_condition) unless skip_other
|
203
|
+
end
|
204
|
+
|
205
|
+
def generate_trend_categories(trend_definition)
|
206
|
+
period_count = trend_definition.delete(:periods) || trend_definition.delete(:period_count) || 14
|
207
|
+
period_length = trend_definition.delete(:period_length) || 1.days
|
208
|
+
trend_end = trend_definition.delete(:end) || Time.now.utc.midnight + 1.day
|
209
|
+
trend_begin = trend_definition.delete(:begin)
|
210
|
+
timestamp_field = trend_definition.delete(:timestamp_field)
|
211
|
+
|
212
|
+
if !trend_end.nil? && trend_begin.nil?
|
213
|
+
trend_begin = trend_end - (period_count * period_length)
|
214
|
+
end
|
215
|
+
|
216
|
+
|
217
|
+
field = @klass.connection.send :quote_column_name, timestamp_field
|
218
|
+
period_begin = trend_begin
|
219
|
+
period_count.times do |i|
|
220
|
+
register_category("period_#{i}".to_sym, {:begin => period_begin, :end => period_begin + period_length,
|
221
|
+
:expression => ["#{field} >= ? AND #{field} < ?", period_begin, period_begin + period_length] })
|
222
|
+
period_begin += period_length
|
223
|
+
end
|
224
|
+
|
225
|
+
# update conditions by only querying records that fall in any periods.
|
226
|
+
@conditions = @klass.send(:merge_conditions, @conditions, ["#{field} >= ? AND #{field} < ?", trend_begin, period_begin])
|
227
|
+
@info[:trend] = true
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module ActiveOLAP::Helpers
|
2
|
+
module ChartHelper
|
3
|
+
|
4
|
+
def included(base)
|
5
|
+
require 'gchartrb'
|
6
|
+
base.send :include, ActiveOLAP::Helpers::DisplayHelper
|
7
|
+
end
|
8
|
+
|
9
|
+
def active_olap_pie_chart(cube, options = {}, html_options = {})
|
10
|
+
raise "Pie charts are only suitable charts for 1-dimensional cubes" if cube.depth > 1
|
11
|
+
raise "Pie charts are only supported with a single aggregate" if cube.aggregates.length > 1
|
12
|
+
|
13
|
+
active_olap_1d_pie_chart(cube, options, html_options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def active_olap_line_chart(cube, options = {}, html_options = {})
|
17
|
+
raise "Line charts are only supported with a single aggregate" if cube.aggregates.length > 1
|
18
|
+
|
19
|
+
case cube.depth
|
20
|
+
when 1; active_olap_1d_line_chart(cube, options, html_options)
|
21
|
+
when 2; active_olap_2d_line_chart(cube, options, html_options)
|
22
|
+
else; raise "Multidimensional line charts are not yet supported"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def active_olap_1d_pie_chart(cube, options = {}, html_options = {})
|
27
|
+
|
28
|
+
# set some default options
|
29
|
+
options[:size] ||= '550x300'
|
30
|
+
options[:legend] = true unless options.has_key?(:legend)
|
31
|
+
html_options[:alt] ||= show_active_olap_cube(cube, :for => :pie_chart)
|
32
|
+
html_options[:size] ||= options[:size]
|
33
|
+
|
34
|
+
chart = GoogleChart::PieChart.new(options[:size])
|
35
|
+
chart.show_legend = options[:legend]
|
36
|
+
cube.each do |category, value|
|
37
|
+
if category.info[:color]
|
38
|
+
color = category.info[:color]
|
39
|
+
color = $1 if color =~ /^\#([A-f0-9]{6})$/
|
40
|
+
chart.data show_active_olap_category(category, :for => :pie_chart), value, color
|
41
|
+
else
|
42
|
+
chart.data show_active_olap_category(category, :for => :pie_chart), value
|
43
|
+
end
|
44
|
+
end
|
45
|
+
image_tag(chart.to_url, html_options)
|
46
|
+
end
|
47
|
+
|
48
|
+
def active_olap_1d_line_chart(cube, options = {}, html_options = {})
|
49
|
+
|
50
|
+
# set some default options
|
51
|
+
options[:size] ||= '550x300'
|
52
|
+
options[:legend] = false unless options.has_key?(:legend)
|
53
|
+
html_options[:alt] ||= show_active_olap_cube(cube, :for => :line_chart)
|
54
|
+
html_options[:size] ||= options[:size]
|
55
|
+
|
56
|
+
chart = GoogleChart::LineChart.new(options[:size])
|
57
|
+
labels = cube.categories.map { |cat| show_active_olap_period(cat, :for => :line_chart) }
|
58
|
+
|
59
|
+
color = options[:color] || '000000'
|
60
|
+
color = $1 if color =~ /^\#([A-f0-9]{6})$/
|
61
|
+
chart.data(show_active_olap_dimension(cube.dimension, :for => :line_chart), cube.to_a, color)
|
62
|
+
chart.show_legend = options[:legend]
|
63
|
+
chart.axis :x, :labels => labels
|
64
|
+
chart.axis :y, :range => [0, cube.raw_results.max]
|
65
|
+
image_tag(chart.to_url, html_options)
|
66
|
+
end
|
67
|
+
|
68
|
+
def active_olap_2d_line_chart(cube, options = {}, html_options = {})
|
69
|
+
|
70
|
+
# set some default options
|
71
|
+
options[:size] ||= '550x300'
|
72
|
+
options[:legend] = true unless options.has_key?(:legend) && options[:legend] == false
|
73
|
+
colors = options.has_key?(:colors) ? options[:colors].clone : ['aaaaaa', 'aa0000', '00aa00', '0000aa', '222222']
|
74
|
+
html_options[:alt] ||= show_active_olap_cube(cube, :for => :line_chart)
|
75
|
+
html_options[:size] ||= options[:size]
|
76
|
+
|
77
|
+
chart = GoogleChart::LineChart.new(options[:size])
|
78
|
+
|
79
|
+
cube.transpose.each do |cat, sub_cube|
|
80
|
+
color = cat.info[:color] || colors.shift || '666666'
|
81
|
+
color = $1 if color =~ /^\#([A-f0-9]{6})$/
|
82
|
+
chart.data show_active_olap_category(cat, :for => :line_chart), sub_cube.raw_results, color
|
83
|
+
end
|
84
|
+
|
85
|
+
if options[:totals]
|
86
|
+
totals_label = options[:totals_label] || "Total"
|
87
|
+
totals_color = options[:totals_color] || "000000"
|
88
|
+
totals_color = $1 if totals_color =~ /^\#([A-f0-9]{6})$/
|
89
|
+
chart.data totals_label, cube.map { |cat, result| result.raw_results.sum }, totals_color
|
90
|
+
end
|
91
|
+
|
92
|
+
chart.show_legend = options[:legend]
|
93
|
+
labels = cube.categories.map { |cat| show_active_olap_period(cat, :for => :line_chart) }
|
94
|
+
chart.axis :x, :labels => labels
|
95
|
+
chart.axis :y, :range => [0, cube.raw_results.flatten.max]
|
96
|
+
image_tag(chart.to_url, html_options)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module ActiveOLAP::Helpers
|
2
|
+
module DisplayHelper
|
3
|
+
|
4
|
+
|
5
|
+
def show_active_olap_category(category, options = {})
|
6
|
+
return category.info[:name] unless category.info[:name].blank?
|
7
|
+
return show_active_olap_period(category, options) if category.dimension.is_time_dimension?
|
8
|
+
return category.to_s.humanize if category.label.kind_of?(Symbol)
|
9
|
+
return category.to_s
|
10
|
+
end
|
11
|
+
|
12
|
+
def show_active_olap_period(category, options = {})
|
13
|
+
|
14
|
+
duration = category.info[:end] - category.info[:begin]
|
15
|
+
if duration < 1.hour
|
16
|
+
begin_time = category.info[:begin].strftime('%H:%M')
|
17
|
+
end_time = category.info[:end].strftime('%H:%M')
|
18
|
+
elsif duration < 1.day
|
19
|
+
begin_time = category.info[:begin].strftime('%H')
|
20
|
+
end_time = category.info[:end].strftime('%H')
|
21
|
+
elsif duration < 1.month
|
22
|
+
begin_time = category.info[:begin].strftime('%m/%d')
|
23
|
+
end_time = category.info[:end].strftime('%m/%d')
|
24
|
+
else
|
25
|
+
begin_time = category.info[:begin].strftime('\'%y/%m/%d')
|
26
|
+
end_time = category.info[:end].strftime('\'%y/%m/%d')
|
27
|
+
end
|
28
|
+
|
29
|
+
case options[:for]
|
30
|
+
when :line_chart; begin_time
|
31
|
+
else; "#{begin_time} - #{end_time}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
def show_active_olap_aggregate(aggregate, options = {})
|
37
|
+
aggregate.info[:name].blank? ? aggregate.label.to_s : aggregate.info[:name]
|
38
|
+
end
|
39
|
+
|
40
|
+
def show_active_olap_value(category, aggregate, value, options = {})
|
41
|
+
value.nil? ? '-' : value.to_s
|
42
|
+
end
|
43
|
+
|
44
|
+
def show_active_olap_dimension(dimension, options = {})
|
45
|
+
return dimension.info[:name] unless dimension.info[:name].blank?
|
46
|
+
"dimension"
|
47
|
+
end
|
48
|
+
|
49
|
+
def show_active_olap_cube(cube, options = {})
|
50
|
+
return cube.info[:name] unless cube.info[:name].blank?
|
51
|
+
"OLAP cube"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module ActiveOLAP::Helpers
|
2
|
+
module FormHelper
|
3
|
+
|
4
|
+
def select_olap_dimension_tag(klass, dim_index = 1, options = {}, html_options = {})
|
5
|
+
dimensions = klass.active_olap_dimensions.map { |l, dim| [l, ActiveOLAP::Dimension.create(klass, l)] }
|
6
|
+
dimensions.delete_if { |(l, dim)| dim.is_time_dimension? }.map { |(l, dim)| [l, show_active_olap_dimension(dim)] }
|
7
|
+
select_tag "dimension[#{dim_index}][name]", options_for_select(dimensions, nil), html_options
|
8
|
+
end
|
9
|
+
|
10
|
+
def select_olap_time_dimension_tag(klass, dim_index = 1, options = {}, html_options = {})
|
11
|
+
dimensions = klass.active_olap_dimensions.map { |l, dim| [l, ActiveOLAP::Dimension.create(klass, l)] }
|
12
|
+
dimensions.delete_if { |(l, dim)| !dim.is_time_dimension? }.map { |(l, dim)| [l, show_active_olap_dimension(dim)] }
|
13
|
+
select_tag "dimension[#{dim_index}][name]", options_for_select(dimensions, nil), html_options
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module ActiveOLAP::Helpers
|
2
|
+
module TableHelper
|
3
|
+
|
4
|
+
|
5
|
+
def self.included(base)
|
6
|
+
base.send :include, ActiveOLAP::Helpers::DisplayHelper
|
7
|
+
end
|
8
|
+
|
9
|
+
def active_olap_matrix(cube, options = {}, html_options = {})
|
10
|
+
|
11
|
+
raise "Only suitable for 2D cubes" unless cube.depth == 2
|
12
|
+
raise "Only 1 aggregate supported" if cube.aggregates.length > 1
|
13
|
+
options[:strings] ||= {}
|
14
|
+
|
15
|
+
content_tag(:table, html_options.merge(:class => "active-olap table 2d")) do
|
16
|
+
content_tag(:thead) do
|
17
|
+
content_tag(:tr, :class => 'category') do
|
18
|
+
header_html = content_tag(:th, ' ') + "\n\t" +
|
19
|
+
cube.dimensions[1].categories.map do |category|
|
20
|
+
content_tag(:th, show_active_olap_category(category, :for => :matrix), :class => 'column', :id => "category-#{category.label}")
|
21
|
+
end.join
|
22
|
+
|
23
|
+
if options[:totals]
|
24
|
+
header_html << content_tag(:th, ' ', :class => 'column total', :id => "category-total")
|
25
|
+
end
|
26
|
+
header_html
|
27
|
+
end
|
28
|
+
end << "\n" <<
|
29
|
+
content_tag(:tbody) do
|
30
|
+
body_html = cube.map do |category, sub_cube|
|
31
|
+
content_tag(:tr, :class => 'row') do
|
32
|
+
row_html = "\t\n" + content_tag(:th, show_active_olap_category(category, :class => 'category row', :id => "category-#{category.label}", :for => :matrix)) +
|
33
|
+
sub_cube.map do |category, value|
|
34
|
+
content_tag(:td, show_active_olap_value(category, cube.aggregates.first, value, :for => :matrix), :class => 'value')
|
35
|
+
end.join
|
36
|
+
if options[:totals]
|
37
|
+
row_html << content_tag(:td, show_active_olap_value(category, sub_cube.aggregates.first, sub_cube.sum, :for => :matrix), :class => 'value')
|
38
|
+
end
|
39
|
+
row_html
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
if options[:totals]
|
44
|
+
body_html << content_tag(:tr, :class => 'totals') do
|
45
|
+
"\t\n" + content_tag(:th, ' ', :class => 'row total') +
|
46
|
+
cube.transpose.map do |category, sub_cube|
|
47
|
+
content_tag(:td, show_active_olap_value(category, sub_cube.aggregates.first, sub_cube.sum, :for => :matrix), :class => 'value')
|
48
|
+
end.join +
|
49
|
+
content_tag(:td, cube.sum, :class => 'value')
|
50
|
+
end
|
51
|
+
end
|
52
|
+
body_html
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def active_olap_table(cube, options = {}, html_options = {})
|
58
|
+
content_tag(:table, :class => "active-olap table #{cube.depth}d" ) do
|
59
|
+
content_tag(:thead) do
|
60
|
+
content_tag(:tr) do
|
61
|
+
content_tag(:th, ' ', :class => 'categories', :colspan => cube.depth) <<
|
62
|
+
cube.aggregates.map { |agg| content_tag(:th, show_active_olap_aggregate(agg, :for => :table), :class => "aggregate #{agg.label}") }.join
|
63
|
+
end
|
64
|
+
end << "\n" <<
|
65
|
+
content_tag(:tbody) { active_olap_table_bodypart(cube, options) }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def active_olap_table_bodypart(cube, options = {}, intermediate = [], counts = [])
|
70
|
+
cube.map do |category, result|
|
71
|
+
if result.kind_of?(ActiveOLAP::Cube)
|
72
|
+
active_olap_table_bodypart(result, options, intermediate.push(category), counts.push(result.categories.length))
|
73
|
+
else
|
74
|
+
content_tag(:tr) do
|
75
|
+
cells = intermediate.map do |c|
|
76
|
+
cat_count = counts.shift
|
77
|
+
content_tag(:th, c.label.to_s, { :class => 'category', :rowspan => cat_count * counts.inject(1) { |i, count| i * count } } )
|
78
|
+
end.join
|
79
|
+
intermediate.clear
|
80
|
+
|
81
|
+
cells << content_tag(:th, show_active_olap_category(category, :for => :table), :class => "category") # TODO values
|
82
|
+
cells << if result.kind_of?(Hash)
|
83
|
+
cube.aggregates.map { |agg| content_tag(:td, show_active_olap_value(category, agg, result[agg.label], :for => :table), :class => "value #{agg.label}") }.join
|
84
|
+
else
|
85
|
+
content_tag(:td, show_active_olap_value(category, cube.aggregates[0], result, :for => :table), :class => 'value')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end.join
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module ActiveOLAP::Test
|
2
|
+
module Assertions
|
3
|
+
|
4
|
+
# tests whether Active OLAP is enabled for a given class, by checking
|
5
|
+
# whether the class responds to certain methods like olap_query
|
6
|
+
def assert_active_olap_enabled(klass, message = "Active OLAP is not enabled for this class!")
|
7
|
+
assert klass.respond_to?(:active_olap_dimensions), message
|
8
|
+
assert klass.respond_to?(:active_olap_aggregates), message
|
9
|
+
assert klass.respond_to?(:olap_query), message
|
10
|
+
assert klass.respond_to?(:olap_drilldown), message
|
11
|
+
end
|
12
|
+
|
13
|
+
# tests whether a given cube is a valid Active OLAP cube and has the expected dimensions
|
14
|
+
# you can specify the number of dimenions as an integer (say n), or as an array of n integers,
|
15
|
+
# in which each element is the number of categories that should be in that dimension. You can
|
16
|
+
# use :unknown if this is not known beforehand
|
17
|
+
#
|
18
|
+
# examples:
|
19
|
+
#
|
20
|
+
# assert_active_olap_cube cube, 2
|
21
|
+
# => checks for the existence of two dimensions
|
22
|
+
#
|
23
|
+
# assert_active_olap_cube cube, [3, :unknown]
|
24
|
+
# => Checks for the existence of two dimensions.
|
25
|
+
# - The first dimension should have 3 catgeories
|
26
|
+
# - The number of categories in the second dimension is unknown
|
27
|
+
def assert_active_olap_cube(cube, dimensions = nil)
|
28
|
+
assert_kind_of ActiveOLAP::Cube, cube
|
29
|
+
if dimensions.kind_of?(Array)
|
30
|
+
assert_equal dimensions.length, cube.depth
|
31
|
+
dimensions.each_with_index do |category_count, index|
|
32
|
+
assert_equal category_count, cube.dimensions[index].categories.length unless category_count == :unknown
|
33
|
+
end
|
34
|
+
elsif dimensions.kind_of?(Numeric)
|
35
|
+
assert_equal dimensions, cube.depth
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/active_olap.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
module ActiveOLAP
|
2
|
+
|
3
|
+
def enable_active_olap(config = nil, &block)
|
4
|
+
|
5
|
+
self.send(:extend, ClassMethods)
|
6
|
+
self.named_scope :olap_drilldown, lambda { |hash| self.olap_drilldown_finder_options(hash) }
|
7
|
+
|
8
|
+
self.cattr_accessor :active_olap_dimensions, :active_olap_aggregates
|
9
|
+
self.active_olap_dimensions = {}
|
10
|
+
self.active_olap_aggregates = {}
|
11
|
+
|
12
|
+
if config.nil? && block_given?
|
13
|
+
conf = Configurator.new(self)
|
14
|
+
yield(conf)
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
|
22
|
+
# Performs an OLAP query that counts how many records do occur in given categories.
|
23
|
+
# It can be used for multiple dimensions
|
24
|
+
# It expects a list of category definitions
|
25
|
+
def olap_query(*args)
|
26
|
+
|
27
|
+
# set aggregates apart if they are given
|
28
|
+
aggregates_given = (args.last.kind_of?(Hash) && args.last.has_key?(:aggregate)) ? args.pop[:aggregate] : nil
|
29
|
+
|
30
|
+
# parse the dimensions
|
31
|
+
raise "You have to provide at least one dimension for an OLAP query" if args.length == 0
|
32
|
+
dimensions = args.collect { |d| Dimension.create(self, d) }
|
33
|
+
|
34
|
+
raise "Overlapping categories only supported in the last dimension" if dimensions[0..-2].any? { |d| d.has_overlap? }
|
35
|
+
raise "Only counting is supported with overlapping categories" if dimensions.last.has_overlap? && aggregates_given
|
36
|
+
|
37
|
+
if !aggregates_given
|
38
|
+
if dimensions.last.has_overlap?
|
39
|
+
aggregates = []
|
40
|
+
else
|
41
|
+
aggregates = [Aggregate.create(self, :the_olap_count_field, :count_distinct)]
|
42
|
+
end
|
43
|
+
else
|
44
|
+
aggregates = Aggregate.all_from_olap_query_call(self, aggregates_given)
|
45
|
+
end
|
46
|
+
|
47
|
+
conditions = self.send(:merge_conditions, *dimensions.map(&:conditions))
|
48
|
+
joins = (dimensions.map(&:joins) + aggregates.map(&:joins)).flatten.uniq
|
49
|
+
joins_clause = joins.empty? ? nil : self.send(:merge_joins, *joins)
|
50
|
+
|
51
|
+
selects = aggregates.map { |agg| agg.to_sanitized_sql }
|
52
|
+
groups = []
|
53
|
+
|
54
|
+
if aggregates.length > 0
|
55
|
+
dimensions_to_group = dimensions.clone
|
56
|
+
else
|
57
|
+
selects << dimensions.last.to_count_with_overlap_sql
|
58
|
+
dimensions_to_group = dimensions[0, dimensions.length - 1]
|
59
|
+
end
|
60
|
+
|
61
|
+
dimensions_to_group.each_with_index do |d, index|
|
62
|
+
var_name = "dimension_#{index}"
|
63
|
+
groups << self.connection.quote_column_name(var_name)
|
64
|
+
selects << d.to_case_expression(var_name)
|
65
|
+
end
|
66
|
+
|
67
|
+
group_clause = groups.length > 0 ? groups.join(', ') : nil
|
68
|
+
# TODO: having
|
69
|
+
|
70
|
+
olap_temporarily_set_join_type if joins_clause
|
71
|
+
|
72
|
+
query_result = self.scoped(:conditions => conditions).find(:all, :select => selects.join(', '),
|
73
|
+
:joins => joins_clause, :group => group_clause, :order => group_clause)
|
74
|
+
|
75
|
+
olap_temporarily_reset_join_type if joins_clause
|
76
|
+
|
77
|
+
return Cube.new(self, dimensions, aggregates, query_result)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
protected
|
82
|
+
|
83
|
+
def olap_drilldown_finder_options(options)
|
84
|
+
raise "You have to provide at least one dimension for an OLAP query" if options.length == 0
|
85
|
+
|
86
|
+
# returns an options hash to create a scope (the named_scope :olap_drilldown)
|
87
|
+
conditions = options.map { |dim, cat| Dimension.create(self, dim).sanitized_sql_for(cat) }
|
88
|
+
{ :select => connection.quote_table_name(table_name) + '.*', :conditions => self.send(:merge_conditions, *conditions) }
|
89
|
+
end
|
90
|
+
|
91
|
+
# temporarily use LEFT JOINs for specified :joins
|
92
|
+
def olap_temporarily_set_join_type
|
93
|
+
ActiveRecord::Associations::ClassMethods::InnerJoinDependency::InnerJoinAssociation.send(:define_method, :join_type) { "LEFT OUTER JOIN" }
|
94
|
+
end
|
95
|
+
|
96
|
+
# reset to INNER JOINs after query has finished
|
97
|
+
def olap_temporarily_reset_join_type
|
98
|
+
ActiveRecord::Associations::ClassMethods::InnerJoinDependency::InnerJoinAssociation.send(:define_method, :join_type) { "INNER JOIN" }
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
require 'active_olap/dimension'
|
105
|
+
require 'active_olap/category'
|
106
|
+
require 'active_olap/aggregate'
|
107
|
+
require 'active_olap/cube'
|
108
|
+
require 'active_olap/configurator'
|
109
|
+
|
110
|
+
require 'active_olap/helpers/display_helper'
|
111
|
+
require 'active_olap/helpers/table_helper'
|
112
|
+
require 'active_olap/helpers/chart_helper'
|
113
|
+
require 'active_olap/helpers/form_helper'
|
114
|
+
|
115
|
+
# inlcude the AcvtiveOLAP module in ActiveRecord::Base
|
116
|
+
ActiveRecord::Base.send(:extend, ActiveOLAP)
|
@@ -0,0 +1 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper.rb'
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
$:.reject! { |e| e.include? 'TextMate' }
|
2
|
+
$:.unshift(File.dirname(__FILE__) + '/../lib')
|
3
|
+
|
4
|
+
# load RSpec libraries
|
5
|
+
require 'rubygems'
|
6
|
+
gem 'rspec', '>=1.1.11'
|
7
|
+
require 'test/unit'
|
8
|
+
require 'spec'
|
9
|
+
|
10
|
+
|
11
|
+
# Load Active OLAP files
|
12
|
+
require 'active_record'
|
13
|
+
require File.dirname(__FILE__) + '/../lib/active_record/olap'
|
14
|
+
require File.dirname(__FILE__) + '/../lib/active_record/olap/cube'
|