active_reporting 0.0.1 → 0.1.0
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.
- checksums.yaml +4 -4
- data/.codeclimate.yml +15 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +25 -0
- data/.travis.yml +13 -2
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +1 -1
- data/README.md +301 -5
- data/Rakefile +5 -5
- data/active_reporting.gemspec +20 -13
- data/bin/console +3 -3
- data/gemfiles/4.0.gemfile +5 -0
- data/gemfiles/5.0.gemfile +5 -0
- data/lib/active_reporting.rb +39 -2
- data/lib/active_reporting/active_record_adaptor.rb +21 -0
- data/lib/active_reporting/configuration.rb +80 -0
- data/lib/active_reporting/dimension.rb +59 -0
- data/lib/active_reporting/dimension_filter.rb +30 -0
- data/lib/active_reporting/fact_model.rb +170 -0
- data/lib/active_reporting/metric.rb +49 -0
- data/lib/active_reporting/report.rb +149 -0
- data/lib/active_reporting/reporting_dimension.rb +102 -0
- data/lib/active_reporting/version.rb +1 -1
- metadata +78 -8
data/Rakefile
CHANGED
@@ -1,10 +1,10 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'rake/testtask'
|
3
3
|
|
4
4
|
Rake::TestTask.new(:test) do |t|
|
5
|
-
t.libs <<
|
6
|
-
t.libs <<
|
5
|
+
t.libs << 'test'
|
6
|
+
t.libs << 'lib'
|
7
7
|
t.test_files = FileList['test/**/*_test.rb']
|
8
8
|
end
|
9
9
|
|
10
|
-
task :
|
10
|
+
task default: :test
|
data/active_reporting.gemspec
CHANGED
@@ -4,22 +4,29 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
4
|
require 'active_reporting/version'
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
|
-
spec.name =
|
7
|
+
spec.name = 'active_reporting'
|
8
8
|
spec.version = ActiveReporting::VERSION
|
9
|
-
spec.authors = [
|
10
|
-
spec.email = [
|
9
|
+
spec.authors = ['Tony Drake']
|
10
|
+
spec.email = ['t27duck@gmail.com']
|
11
11
|
|
12
|
-
spec.summary =
|
13
|
-
# spec.description =
|
14
|
-
spec.homepage =
|
15
|
-
spec.license =
|
12
|
+
spec.summary = 'Add relational OLAP-like functionality for ActiveRecord'
|
13
|
+
# spec.description = 'TODO: Write a longer description or delete this line.'
|
14
|
+
spec.homepage = 'https://github.com/t27duck/active_reporting'
|
15
|
+
spec.license = 'MIT'
|
16
16
|
|
17
|
-
spec.files
|
18
|
-
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = 'exe'
|
19
21
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
-
spec.require_paths = [
|
22
|
+
spec.require_paths = ['lib']
|
21
23
|
|
22
|
-
spec.
|
23
|
-
spec.
|
24
|
-
|
24
|
+
spec.add_dependency 'activerecord', '>= 4.2.0'
|
25
|
+
spec.add_dependency 'activesupport', '>= 4.2.0'
|
26
|
+
|
27
|
+
spec.add_development_dependency 'bundler'
|
28
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
29
|
+
spec.add_development_dependency 'minitest', '~> 5.0'
|
30
|
+
spec.add_development_dependency 'ransack'
|
31
|
+
spec.add_development_dependency 'sqlite3'
|
25
32
|
end
|
data/bin/console
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'active_reporting'
|
5
5
|
|
6
6
|
# You can add fixtures and/or initialization code here to make experimenting
|
7
7
|
# with your gem easier. You can also use a different console, if you like.
|
@@ -10,5 +10,5 @@ require "active_reporting"
|
|
10
10
|
# require "pry"
|
11
11
|
# Pry.start
|
12
12
|
|
13
|
-
require
|
13
|
+
require 'irb'
|
14
14
|
IRB.start
|
data/lib/active_reporting.rb
CHANGED
@@ -1,5 +1,42 @@
|
|
1
|
-
require
|
1
|
+
require 'active_record'
|
2
|
+
require 'active_reporting/active_record_adaptor'
|
3
|
+
require 'active_reporting/configuration'
|
4
|
+
require 'active_reporting/dimension'
|
5
|
+
require 'active_reporting/dimension_filter'
|
6
|
+
require 'active_reporting/metric'
|
7
|
+
require 'active_reporting/fact_model'
|
8
|
+
require 'active_reporting/report'
|
9
|
+
require 'active_reporting/reporting_dimension'
|
10
|
+
require 'active_reporting/version'
|
11
|
+
|
12
|
+
begin
|
13
|
+
require 'ransack'
|
14
|
+
ActiveReporting::Configuration.ransack_available = true
|
15
|
+
rescue
|
16
|
+
ActiveReporting::Configuration.ransack_available = false
|
17
|
+
end
|
18
|
+
|
19
|
+
ActiveRecord::Base.extend(ActiveReporting::ActiveRecordAdaptor)
|
2
20
|
|
3
21
|
module ActiveReporting
|
4
|
-
|
22
|
+
def self.fetch_metric(name)
|
23
|
+
klass = Configuration.metric_lookup_class
|
24
|
+
unless defined?(klass.constantize)
|
25
|
+
raise BadMetricLookupClass,
|
26
|
+
"#{klass} not defined. Please define a class responsible for looking up a metric by name." +
|
27
|
+
" You may define your own class and set it with `ActiveReporting::Configuration.metric_lookup_class=`."
|
28
|
+
end
|
29
|
+
unless klass.constantize.respond_to?(:lookup)
|
30
|
+
raise BadMetricLookupClass, "#{klass} needs to define a class method called 'lookup'"
|
31
|
+
end
|
32
|
+
klass.constantize.lookup(name)
|
33
|
+
end
|
34
|
+
|
35
|
+
BadMetricLookupClass = Class.new(StandardError)
|
36
|
+
InvalidDimensionLabel = Class.new(StandardError)
|
37
|
+
RansackNotAvailable = Class.new(StandardError)
|
38
|
+
UnknownAggregate = Class.new(StandardError)
|
39
|
+
UnknownDimension = Class.new(StandardError)
|
40
|
+
UnknownDimensionFilter = Class.new(StandardError)
|
41
|
+
UnknownMetric = Class.new(StandardError)
|
5
42
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module ActiveReporting
|
2
|
+
# This is included into every class that inherits from ActiveRecord::Base
|
3
|
+
module ActiveRecordAdaptor
|
4
|
+
# Returns the ActiveReporting::FactModel related to the model.
|
5
|
+
#
|
6
|
+
# If one is not explictily defined, a constant will be created which
|
7
|
+
# inherits from ActiveReporting::Factmodel named [MyModel]FactModel
|
8
|
+
#
|
9
|
+
# @return [ActiveReporting::FactModel]
|
10
|
+
def fact_model
|
11
|
+
const_name = "#{name}FactModel"
|
12
|
+
@fact_model ||= begin
|
13
|
+
const_name.constantize
|
14
|
+
rescue NameError
|
15
|
+
const = Object.const_set(const_name, Class.new(ActiveReporting::FactModel))
|
16
|
+
const.use_model self
|
17
|
+
const
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module ActiveReporting
|
2
|
+
module Configuration
|
3
|
+
class << self
|
4
|
+
# Determines if ransack is available for use in the gem
|
5
|
+
#
|
6
|
+
# @return [Boolean]
|
7
|
+
attr_accessor :ransack_available
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.config
|
11
|
+
yield self
|
12
|
+
end
|
13
|
+
|
14
|
+
# The default label used by all dimensions if not set otherwise
|
15
|
+
#
|
16
|
+
# Default value is `:name`
|
17
|
+
def self.default_dimension_label
|
18
|
+
@default_dimension_label ||= :name
|
19
|
+
end
|
20
|
+
|
21
|
+
# Sets the default dimension label to be used by all dimensions
|
22
|
+
#
|
23
|
+
# @param dimension_label [String, Symbol]
|
24
|
+
# @return [Symbol]
|
25
|
+
def self.default_dimension_label=(dimension_label)
|
26
|
+
@default_dimension_label = dimension_label.to_sym
|
27
|
+
end
|
28
|
+
|
29
|
+
# The default measture for all fact models
|
30
|
+
#
|
31
|
+
# Default value is `:value``
|
32
|
+
def self.default_measure
|
33
|
+
@default_measure ||= :value
|
34
|
+
end
|
35
|
+
|
36
|
+
# Sets the default measture to be used by all fact models
|
37
|
+
#
|
38
|
+
# @param measure [String, Symbol]
|
39
|
+
# @return [Symbol]
|
40
|
+
def self.default_measure=(measure)
|
41
|
+
@default_measure = measure.to_sym
|
42
|
+
end
|
43
|
+
|
44
|
+
# Tells if unkown dimension filters should always fallback to ransack
|
45
|
+
#
|
46
|
+
# Default value is `false`
|
47
|
+
#
|
48
|
+
# @return [Boolean]
|
49
|
+
def self.ransack_fallback
|
50
|
+
@ransack_fallback ||= false
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sets the flag to always fallback to ransack for unknown dimension filters
|
54
|
+
# @param fallback [Boolean]
|
55
|
+
# @return [Boolean]
|
56
|
+
def self.ransack_fallback=(fallback)
|
57
|
+
raise RansackNotAvailable unless ransack_available
|
58
|
+
@ransack_fallback = fallback
|
59
|
+
end
|
60
|
+
|
61
|
+
# Sets the name of the constant used to lookup prebuilt `Reporting::Metric`
|
62
|
+
# objects by name.
|
63
|
+
#
|
64
|
+
# @param klass_name [String]
|
65
|
+
def self.metric_lookup_class=(klass_name)
|
66
|
+
@metric_lookup_class = "::#{klass_name.to_s.classify}"
|
67
|
+
end
|
68
|
+
|
69
|
+
# Sets the name of the constant used to lookup prebuilt `Reporting::Metric`
|
70
|
+
# objects by name. The constant should define a class method called `#lookup`
|
71
|
+
# which can take a string or symbol of the metric name.
|
72
|
+
#
|
73
|
+
# Default value is ::Metric
|
74
|
+
#
|
75
|
+
# @returns [String]
|
76
|
+
def self.metric_lookup_class
|
77
|
+
@metric_lookup_class ||= '::Metric'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module ActiveReporting
|
2
|
+
class Dimension
|
3
|
+
attr_reader :name
|
4
|
+
|
5
|
+
# @param model [ActiveRecord::Base]
|
6
|
+
# @param name [Symbol]
|
7
|
+
def initialize(fact_model, name:)
|
8
|
+
@fact_model = fact_model
|
9
|
+
@name = name.to_s
|
10
|
+
end
|
11
|
+
|
12
|
+
# Determins the type of the dimension
|
13
|
+
#
|
14
|
+
# A dimension type is either:
|
15
|
+
#
|
16
|
+
# * standard - The dimension is a relation to the fact model's model
|
17
|
+
# * degenerate - The dimension is the model's attribute
|
18
|
+
#
|
19
|
+
# @return [Symbol]
|
20
|
+
def type
|
21
|
+
@type ||= if model.column_names.include?(@name)
|
22
|
+
:degenerate
|
23
|
+
elsif association
|
24
|
+
:standard
|
25
|
+
else
|
26
|
+
raise UnknownDimension, "Dimension '#{@name}' not found on fact model '#{@fact_model}'"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Tells if the dimension is hierarchical
|
31
|
+
#
|
32
|
+
# @return [Boolean]
|
33
|
+
def hierarchical?
|
34
|
+
@hierarchical ||= !klass.fact_model.hierarchical_levels.empty?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns either the model of the dimension's association or the model
|
38
|
+
# itself if the dimension lives on the fact model
|
39
|
+
#
|
40
|
+
# @return [Boolean]
|
41
|
+
def klass
|
42
|
+
@klass ||= association ? association.klass : model
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns the fact model's dimension
|
46
|
+
#
|
47
|
+
# @return [ActiveRecord::Base]
|
48
|
+
def model
|
49
|
+
@model ||= @fact_model.model
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns the reflected association of the fact model to dimension name
|
53
|
+
#
|
54
|
+
# @return [ActiveRecord::Reflection]
|
55
|
+
def association
|
56
|
+
@association_info ||= model.reflect_on_association(@name)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module ActiveReporting
|
2
|
+
class DimensionFilter
|
3
|
+
attr_reader :type, :body
|
4
|
+
|
5
|
+
# Factory for creating a new DimensionFilter
|
6
|
+
#
|
7
|
+
# Determines the type based on if passed in a callable object or a symbol
|
8
|
+
#
|
9
|
+
# @param name (Symbol)
|
10
|
+
# @param lambda_or_type (Symbol, Lambda)
|
11
|
+
# @return (ActiveReporting::DimensionFilter) a new instance of a dimension filter
|
12
|
+
def self.build(name, lambda_or_type)
|
13
|
+
body = nil
|
14
|
+
type = lambda_or_type
|
15
|
+
|
16
|
+
if lambda_or_type.respond_to?(:call)
|
17
|
+
body = lambda_or_type
|
18
|
+
type = :lambda
|
19
|
+
end
|
20
|
+
|
21
|
+
new(name, type, body)
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(name, type = :scope, body = nil)
|
25
|
+
@name = name.to_sym
|
26
|
+
@type = type.to_sym
|
27
|
+
@body = body
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
module ActiveReporting
|
2
|
+
class FactModel
|
3
|
+
class << self
|
4
|
+
attr_reader :dimensions, :dimension_filters
|
5
|
+
attr_writer :measure
|
6
|
+
end
|
7
|
+
|
8
|
+
# Explicitly sets which ActiveRecord model to to link to this fact model.
|
9
|
+
#
|
10
|
+
# @note You should only need to set this if the name of your fact model does not
|
11
|
+
# follow the pattern of [MyModel]FactModel
|
12
|
+
#
|
13
|
+
# @param model [String, Symbol, Class]
|
14
|
+
# @return [Class] the ActiveRecord model
|
15
|
+
#
|
16
|
+
# @example
|
17
|
+
# class PostFactModel < ActiveReporting::FactModel
|
18
|
+
# use_model :post
|
19
|
+
# use_model Post
|
20
|
+
# use_model 'post'
|
21
|
+
# end
|
22
|
+
def self.use_model(model)
|
23
|
+
@model = if model.is_a?(String) || model.is_a?(Symbol)
|
24
|
+
model.to_s.classify.constantize
|
25
|
+
else
|
26
|
+
model
|
27
|
+
end
|
28
|
+
@model.instance_variable_set('@fact_model', self)
|
29
|
+
@model
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns the ActiveRecord model linked to the FactModel.
|
33
|
+
#
|
34
|
+
# If not already set, FactModel assumes the model is based off of the name of the class
|
35
|
+
#
|
36
|
+
# @example
|
37
|
+
# class PostFactModel < ActiveReporting::FactModel
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# PostFactModel.model
|
41
|
+
# => Post
|
42
|
+
#
|
43
|
+
# @return [Class] the ActiveRecord model
|
44
|
+
def self.model
|
45
|
+
@model ||= name.gsub(/FactModel\z/, '').constantize
|
46
|
+
end
|
47
|
+
|
48
|
+
# The default measure used when this fact model is reported on
|
49
|
+
#
|
50
|
+
# @return [Symbol]
|
51
|
+
def self.measure
|
52
|
+
@measure ||= Configuration.default_measure
|
53
|
+
end
|
54
|
+
|
55
|
+
# The (in order) hierarchical levels of the fact model when used as
|
56
|
+
# a dimension.
|
57
|
+
#
|
58
|
+
# @return [Array]
|
59
|
+
def self.hierarchical_levels
|
60
|
+
@hierarchical_levels ||= []
|
61
|
+
end
|
62
|
+
|
63
|
+
# Specifies an in order array of columns which describes a series of
|
64
|
+
# columns that may be used as dimensions in a hierarchy.
|
65
|
+
#
|
66
|
+
# For example, a fact model of tablets may have a hierarchy of
|
67
|
+
# name -> manufacturer -> operating system.
|
68
|
+
#
|
69
|
+
# @param levels (Array) An array of symbols or strings of columns
|
70
|
+
def self.dimension_hierarchy(levels)
|
71
|
+
@hierarchical_levels = Array(levels).map(&:to_sym)
|
72
|
+
end
|
73
|
+
|
74
|
+
# When this fact model is used as a dimension, this is the label it will
|
75
|
+
# use by default
|
76
|
+
#
|
77
|
+
# @return [Symbol]
|
78
|
+
def self.default_dimension_label(label)
|
79
|
+
@dimension_label = label.to_sym
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns the dimension label used when this fact model is used as a dimension
|
83
|
+
#
|
84
|
+
# @return [Symbol]
|
85
|
+
def self.dimension_label
|
86
|
+
@dimension_label || Configuration.default_dimension_label
|
87
|
+
end
|
88
|
+
|
89
|
+
# Declares a dimension for this fact model
|
90
|
+
#
|
91
|
+
# @param name [String, Symbol] The name of the dimension
|
92
|
+
def self.dimension(name)
|
93
|
+
@dimensions ||= {}
|
94
|
+
@dimensions[name.to_sym] = Dimension.new(self, name: name)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns a hash of dimension label to callback mappings
|
98
|
+
#
|
99
|
+
# @return [Hash]
|
100
|
+
def self.dimension_label_callbacks
|
101
|
+
@dimension_label_callbacks ||= {}
|
102
|
+
end
|
103
|
+
|
104
|
+
# Sets a call back for a given dimension label. The returned value of
|
105
|
+
# the callable body will be used as the label value when used in a report.
|
106
|
+
# The label's raw database value is passed to the callback.
|
107
|
+
#
|
108
|
+
# @param column [Symbol, String]
|
109
|
+
# @param body [Lambda]
|
110
|
+
def self.dimension_label_callback(column, body)
|
111
|
+
@dimension_label_callbacks ||= {}
|
112
|
+
raise ArgumentError, "Dimension label callback body must be a callable object" unless body.respond_to?(:call)
|
113
|
+
@dimension_label_callbacks[column.to_sym] = body
|
114
|
+
end
|
115
|
+
|
116
|
+
# Declares a dimension filter for this fact model
|
117
|
+
#
|
118
|
+
# @param name [Stirng, Symbol] The name of the dimension filter
|
119
|
+
# @param lambda_or_type [Symbol, Lambda]
|
120
|
+
#
|
121
|
+
# @note If not provided, the type of the dimension filter will be a scope.
|
122
|
+
# Meaning the ActiveReporting is assuming there's a scope on the fact
|
123
|
+
# model's model named the same. You may pass in `:ransack` to say this
|
124
|
+
# dimension filter is a ransack search term. Finally, you may pass in
|
125
|
+
# a callable object similar to defining a scope on ActiveRecord
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# class PostFactModel < ActiveReporting::FactModel
|
129
|
+
# # Assumes there's an `active` scope on the model
|
130
|
+
# dimension_filter :active
|
131
|
+
#
|
132
|
+
# # Uses the ransack search term `joined_at_gte`
|
133
|
+
# dimension_filter :joined_at_gte, :ransack
|
134
|
+
#
|
135
|
+
# # Implements a dimension filter like an ActiveRecord scope
|
136
|
+
# dimension_filter :some_filter, ->(input) { where(foo: input) }
|
137
|
+
# end
|
138
|
+
def self.dimension_filter(name, lambda_or_type = :scope)
|
139
|
+
@dimension_filters ||= {}
|
140
|
+
@dimension_filters[name.to_sym] = DimensionFilter.build(name, lambda_or_type)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Invoke this method to make all dimension filters fallback to use ransack
|
144
|
+
# if they are not defined as scopes on the model
|
145
|
+
def self.use_ransack_for_unknown_dimension_filters
|
146
|
+
raise RansackNotAvailable, 'Ransack not available. Please include it in your Gemfile.' unless ransack_available
|
147
|
+
@ransack_fallback = true
|
148
|
+
end
|
149
|
+
|
150
|
+
# Tells if unknown dimension filters fallback to use ransack
|
151
|
+
#
|
152
|
+
# @return [Boolean]
|
153
|
+
def self.ransack_fallback
|
154
|
+
@ransack_fallback || Configuration.ransack_fallback
|
155
|
+
end
|
156
|
+
private_class_method :ransack_fallback
|
157
|
+
|
158
|
+
# Finds a dimension filter defined on a fact model given a name
|
159
|
+
#
|
160
|
+
# @param name [Symbol]
|
161
|
+
# @return [ActiveReporting::DimensionFilter]
|
162
|
+
def self.find_dimension_filter(name)
|
163
|
+
@dimension_filters ||= {}
|
164
|
+
dm = @dimension_filters[name.to_sym]
|
165
|
+
return dm if dm.present?
|
166
|
+
return @dimension_filters[name.to_sym] = DimensionFilter.build(self, name, :ransack) if ransack_fallback
|
167
|
+
raise UnknownDimensionFilter, "Dimension filter '#{name}' not found on fact model '#{self.name}'"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|