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.
@@ -0,0 +1,49 @@
1
+ require 'forwardable'
2
+ module ActiveReporting
3
+ AGGREGATES = %i(count sum max min avg).freeze
4
+
5
+ class Metric
6
+ extend Forwardable
7
+ def_delegators :@fact_model, :model
8
+ attr_reader :fact_model, :name, :dimensions, :dimension_filter, :aggregate, :metric_filter, :order_by_dimension
9
+
10
+ def initialize(
11
+ name,
12
+ fact_model:,
13
+ aggregate: :count,
14
+ dimensions: [],
15
+ dimension_filter: {},
16
+ metric_filter: {},
17
+ order_by_dimension: {}
18
+ )
19
+ @name = name.to_sym
20
+ @fact_model = fact_model
21
+ @dimension_filter = dimension_filter
22
+ @aggregate = determin_aggregate(aggregate.to_sym)
23
+ @metric_filter = metric_filter
24
+ @dimensions = ReportingDimension.build_from_dimensions(@fact_model, Array(dimensions))
25
+ @order_by_dimension = order_by_dimension
26
+ check_dimension_filter
27
+ end
28
+
29
+ # Builds an ActiveReporting::Report object based on the metric
30
+ #
31
+ # @return [ActiveReporting::Report]
32
+ def report
33
+ Report.new(self)
34
+ end
35
+
36
+ private ####################################################################
37
+
38
+ def check_dimension_filter
39
+ @dimension_filter.each do |name, _|
40
+ @fact_model.find_dimension_filter(name)
41
+ end
42
+ end
43
+
44
+ def determin_aggregate(agg)
45
+ raise UnknownAggregate, "Unknown aggregate '#{agg}'" unless AGGREGATES.include?(agg)
46
+ @aggregate = agg
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,149 @@
1
+ require 'forwardable'
2
+ module ActiveReporting
3
+ class Report
4
+ AGGREGATE_FUNCTION_OPERATORS = {
5
+ eq: '=',
6
+ gt: '>',
7
+ gte: '>=',
8
+ lt: '<',
9
+ lte: '<='
10
+ }.freeze
11
+
12
+ extend Forwardable
13
+ def_delegators :@metric, :fact_model, :model
14
+
15
+ def initialize(metric, dimension_identifiers: true, dimension_filter: {}, dimensions: [], metric_filter: {})
16
+ @metric = metric.is_a?(Metric) ? metric : ActiveReporting.fetch_metric(metric)
17
+ raise UnknownMetric, "Unknown metric #{metric}" if @metric.nil?
18
+
19
+ @dimension_identifiers = dimension_identifiers
20
+ local_dimensions = ReportingDimension.build_from_dimensions(fact_model, Array(dimensions))
21
+ @dimensions = (@metric.dimensions + local_dimensions).uniq
22
+ @metric_filter = @metric.metric_filter.merge(metric_filter)
23
+ @ordering = @metric.order_by_dimension
24
+ partition_dimension_filters dimension_filter
25
+ end
26
+
27
+ # Builds and executes a query, returning the raw result
28
+ #
29
+ # @return [Array]
30
+ def run
31
+ @run ||= build_data
32
+ end
33
+
34
+ private ######################################################################
35
+
36
+ def build_data
37
+ @data = model.connection.execute(statement.to_sql).to_a
38
+ apply_dimension_callbacks
39
+ @data
40
+ end
41
+
42
+ def partition_dimension_filters(user_dimension_filter)
43
+ @dimension_filters = { ransack: {}, scope: {}, lambda: {} }
44
+ user_dimension_filter.merge(@metric.dimension_filter).each do |key, value|
45
+ dm = fact_model.find_dimension_filter(key.to_sym)
46
+ @dimension_filters[dm.type][dm] = value
47
+ end
48
+ end
49
+
50
+ def statement
51
+ parts = {
52
+ select: select_statement,
53
+ joins: dimension_joins,
54
+ group: group_by_statement,
55
+ having: having_statement,
56
+ order: order_by_statement
57
+ }
58
+
59
+ statement = ([model] + parts.keys).inject do |chain, method|
60
+ chain.public_send(method, parts[method])
61
+ end
62
+
63
+ statement = process_scope_dimension_filter(statement)
64
+ statement = process_lambda_dimension_filter(statement)
65
+ statement = process_ransack_dimension_filter(statement)
66
+
67
+ statement
68
+ end
69
+
70
+ def select_statement
71
+ ss = ["#{select_aggregate} AS #{@metric.name}"]
72
+ ss += @dimensions.map { |d| d.select_statement(with_identifier: @dimension_identifiers) }
73
+ ss.flatten
74
+ end
75
+
76
+ def select_aggregate
77
+ case @metric.aggregate
78
+ when :count
79
+ 'COUNT(*)'
80
+ else
81
+ "#{@metric.aggregate.to_s.upcase}(#{@metric.measure})"
82
+ end
83
+ end
84
+
85
+ def dimension_joins
86
+ @dimensions.select { |d| d.type == :standard }.map { |d| d.name.to_sym }
87
+ end
88
+
89
+ def group_by_statement
90
+ @dimensions.map { |d| d.group_by_statement(with_identifier: @dimension_identifiers) }
91
+ end
92
+
93
+ def process_scope_dimension_filter(chain)
94
+ @dimension_filters[:scope].each do |dm, args|
95
+ chain = if [true, 'true'].include?(args)
96
+ chain.public_send(dm.name)
97
+ else
98
+ chain.public_send(dm.name, args)
99
+ end
100
+ end
101
+ chain
102
+ end
103
+
104
+ def process_lambda_dimension_filter(chain)
105
+ @dimension_filters[:lambda].each do |df, args|
106
+ chain = if [true, 'true'].include?(args)
107
+ chain.scoping { model.instance_exec(&df.body) }
108
+ else
109
+ chain.scoping { model.instance_exec(args, &df.body) }
110
+ end
111
+ end
112
+ chain
113
+ end
114
+
115
+ def process_ransack_dimension_filter(chain)
116
+ ransack_hash = {}
117
+ @dimension_filters[:ransack].each do |dm, value|
118
+ ransack_hash[dm.name] = value
119
+ end
120
+ chain = chain.ransack(ransack_hash).result if ransack_hash.present?
121
+ chain
122
+ end
123
+
124
+ def having_statement
125
+ @metric_filter.map do |operator, value|
126
+ "#{select_aggregate} #{AGGREGATE_FUNCTION_OPERATORS[operator]} #{value.to_f}"
127
+ end.join(' AND ')
128
+ end
129
+
130
+ def order_by_statement
131
+ [].tap do |o|
132
+ @ordering.each do |dimension_key, direction|
133
+ dim = @dimensions.detect { |d| d.name.to_sym == dimension_key.to_sym }
134
+ o << dim.order_by_statement(direction: direction) if dim
135
+ end
136
+ end
137
+ end
138
+
139
+ def apply_dimension_callbacks
140
+ @dimensions.each do |dimension|
141
+ callback = dimension.label_callback
142
+ next unless callback
143
+ @data.each do |hash|
144
+ hash[dimension.name.to_s] = callback.call(hash[dimension.name.to_s])
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,102 @@
1
+ require 'forwardable'
2
+ module ActiveReporting
3
+ class ReportingDimension
4
+ extend Forwardable
5
+ def_delegators :@dimension, :name, :type, :klass, :association, :model, :hierarchical?
6
+
7
+ def self.build_from_dimensions(fact_model, dimensions)
8
+ Array(dimensions).map do |dim|
9
+ dimension_name, label = dim.is_a?(Hash) ? Array(dim).flatten : [dim, nil]
10
+ found_dimension = fact_model.dimensions[dimension_name.to_sym]
11
+ if found_dimension.nil?
12
+ raise UnknownDimension, "Dimension '#{dim}' not found on fact model '#{fact_model}'"
13
+ end
14
+ new(found_dimension, label: label)
15
+ end
16
+ end
17
+
18
+ def initialize(dimension, label: nil)
19
+ @dimension = dimension
20
+ determine_label(label)
21
+ end
22
+
23
+ # The foreign key to use in queries
24
+ #
25
+ # @return [String]
26
+ def foreign_key
27
+ association ? association.foreign_key : name
28
+ end
29
+
30
+ # Fragments of a select statement for queries that use the dimension
31
+ #
32
+ # @return [Array]
33
+ def select_statement(with_identifier: true)
34
+ return [degenerate_fragment] if type == :degenerate
35
+
36
+ ss = ["#{label_fragment} AS #{name}"]
37
+ ss << "#{identifier_fragment} AS #{name}_identifier" if with_identifier
38
+ ss
39
+ end
40
+
41
+ # Fragments of a group by clause for queries that use the dimension
42
+ #
43
+ # @return [Array]
44
+ def group_by_statement(with_identifier: true)
45
+ return [degenerate_fragment] if type == :degenerate
46
+
47
+ group = [label_fragment]
48
+ group << identifier_fragment if with_identifier
49
+ group
50
+ end
51
+
52
+ # Fragment of an order by clause for queries that sort by the dimension
53
+ #
54
+ # @return [String]
55
+ def order_by_statement(direction:)
56
+ direction = direction.to_s.upcase
57
+ raise "Ording direction should be 'asc' or 'desc'" unless %w(ASC DESC).include?(direction)
58
+ return "#{degenerate_fragment} #{direction}" if type == :degenerate
59
+ "#{label_fragment} #{direction}"
60
+ end
61
+
62
+ # Looks up the dimension label callback for the label
63
+ #
64
+ # @return [Lambda, NilClass]
65
+ def label_callback
66
+ klass.fact_model.dimension_label_callbacks[@label]
67
+ end
68
+
69
+ private ####################################################################
70
+
71
+ def determine_label(label)
72
+ @label = label.to_sym if label.present? && validate_hierarchical_label(label)
73
+ @label ||= dimension_fact_model.dimension_label || Configuration.default_dimension_label
74
+ end
75
+
76
+ def validate_hierarchical_label(hierarchical_label)
77
+ if !hierarchical?
78
+ raise InvalidDimensionLabel, "#{name} must be hierarchical to use label #{hierarchical_label}"
79
+ end
80
+ unless dimension_fact_model.hierarchical_levels.include?(hierarchical_label.to_sym)
81
+ raise InvalidDimensionLabel, "#{hierarchical_label} is not a hierarchical label in #{name}"
82
+ end
83
+ true
84
+ end
85
+
86
+ def degenerate_fragment
87
+ "#{model.quoted_table_name}.#{name}"
88
+ end
89
+
90
+ def identifier_fragment
91
+ "#{klass.quoted_table_name}.#{klass.primary_key}"
92
+ end
93
+
94
+ def label_fragment
95
+ "#{klass.quoted_table_name}.#{@label}"
96
+ end
97
+
98
+ def dimension_fact_model
99
+ @dimension_fact_model ||= klass.fact_model
100
+ end
101
+ end
102
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveReporting
2
- VERSION = "0.0.1"
2
+ VERSION = '0.1.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,29 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_reporting
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Drake
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-08-24 00:00:00.000000000 Z
11
+ date: 2017-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 4.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 4.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 4.2.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 4.2.0
13
41
  - !ruby/object:Gem::Dependency
14
42
  name: bundler
15
43
  requirement: !ruby/object:Gem::Requirement
16
44
  requirements:
17
- - - "~>"
45
+ - - ">="
18
46
  - !ruby/object:Gem::Version
19
- version: '1.12'
47
+ version: '0'
20
48
  type: :development
21
49
  prerelease: false
22
50
  version_requirements: !ruby/object:Gem::Requirement
23
51
  requirements:
24
- - - "~>"
52
+ - - ">="
25
53
  - !ruby/object:Gem::Version
26
- version: '1.12'
54
+ version: '0'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rake
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +80,34 @@ dependencies:
52
80
  - - "~>"
53
81
  - !ruby/object:Gem::Version
54
82
  version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: ransack
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sqlite3
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
55
111
  description:
56
112
  email:
57
113
  - t27duck@gmail.com
@@ -59,8 +115,12 @@ executables: []
59
115
  extensions: []
60
116
  extra_rdoc_files: []
61
117
  files:
118
+ - ".codeclimate.yml"
62
119
  - ".gitignore"
120
+ - ".rubocop.yml"
63
121
  - ".travis.yml"
122
+ - CHANGELOG.md
123
+ - CODE_OF_CONDUCT.md
64
124
  - Gemfile
65
125
  - LICENSE.txt
66
126
  - README.md
@@ -68,7 +128,17 @@ files:
68
128
  - active_reporting.gemspec
69
129
  - bin/console
70
130
  - bin/setup
131
+ - gemfiles/4.0.gemfile
132
+ - gemfiles/5.0.gemfile
71
133
  - lib/active_reporting.rb
134
+ - lib/active_reporting/active_record_adaptor.rb
135
+ - lib/active_reporting/configuration.rb
136
+ - lib/active_reporting/dimension.rb
137
+ - lib/active_reporting/dimension_filter.rb
138
+ - lib/active_reporting/fact_model.rb
139
+ - lib/active_reporting/metric.rb
140
+ - lib/active_reporting/report.rb
141
+ - lib/active_reporting/reporting_dimension.rb
72
142
  - lib/active_reporting/version.rb
73
143
  homepage: https://github.com/t27duck/active_reporting
74
144
  licenses:
@@ -90,8 +160,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
160
  version: '0'
91
161
  requirements: []
92
162
  rubyforge_project:
93
- rubygems_version: 2.4.5.1
163
+ rubygems_version: 2.6.11
94
164
  signing_key:
95
165
  specification_version: 4
96
- summary: OLAP-like functionality that works with ActiveRecord
166
+ summary: Add relational OLAP-like functionality for ActiveRecord
97
167
  test_files: []