active_reporting 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1,10 +1,10 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
3
 
4
4
  Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
7
  t.test_files = FileList['test/**/*_test.rb']
8
8
  end
9
9
 
10
- task :default => :test
10
+ task default: :test
@@ -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 = "active_reporting"
7
+ spec.name = 'active_reporting'
8
8
  spec.version = ActiveReporting::VERSION
9
- spec.authors = ["Tony Drake"]
10
- spec.email = ["t27duck@gmail.com"]
9
+ spec.authors = ['Tony Drake']
10
+ spec.email = ['t27duck@gmail.com']
11
11
 
12
- spec.summary = %q{OLAP-like functionality that works with ActiveRecord}
13
- # spec.description = %q{Write a longer description or delete this line.}
14
- spec.homepage = "https://github.com/t27duck/active_reporting"
15
- spec.license = "MIT"
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 = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
- spec.bindir = "exe"
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 = ["lib"]
22
+ spec.require_paths = ['lib']
21
23
 
22
- spec.add_development_dependency "bundler", "~> 1.12"
23
- spec.add_development_dependency "rake", "~> 10.0"
24
- spec.add_development_dependency "minitest", "~> 5.0"
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 "bundler/setup"
4
- require "active_reporting"
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 "irb"
13
+ require 'irb'
14
14
  IRB.start
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "activerecord", "~> 4.0.0"
4
+
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "activerecord", "~> 5.0.0"
4
+
5
+ gemspec :path=>"../"
@@ -1,5 +1,42 @@
1
- require "active_reporting/version"
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
- # Your code goes here...
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