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.
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