active_olap 0.0.2
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/.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'
|