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.
@@ -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: []