martyr 0.1.74.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +2 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.tags +868 -0
  7. data/.travis.yml +3 -0
  8. data/Gemfile +4 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +265 -0
  11. data/Rakefile +1 -0
  12. data/TODO.txt +54 -0
  13. data/bin/console +62 -0
  14. data/bin/setup +7 -0
  15. data/lib/martyr/base_cube.rb +73 -0
  16. data/lib/martyr/cube.rb +134 -0
  17. data/lib/martyr/dimension_reference.rb +26 -0
  18. data/lib/martyr/errors.rb +20 -0
  19. data/lib/martyr/helpers/delegators.rb +17 -0
  20. data/lib/martyr/helpers/intervals.rb +222 -0
  21. data/lib/martyr/helpers/metric_id_standardizer.rb +47 -0
  22. data/lib/martyr/helpers/registrable.rb +15 -0
  23. data/lib/martyr/helpers/sorter.rb +79 -0
  24. data/lib/martyr/helpers/translations.rb +34 -0
  25. data/lib/martyr/level_concern/has_level_collection.rb +11 -0
  26. data/lib/martyr/level_concern/level.rb +45 -0
  27. data/lib/martyr/level_concern/level_collection.rb +60 -0
  28. data/lib/martyr/level_concern/level_comparator.rb +45 -0
  29. data/lib/martyr/level_concern/level_definitions_by_dimension.rb +24 -0
  30. data/lib/martyr/runtime/data_set/coordinates.rb +108 -0
  31. data/lib/martyr/runtime/data_set/element.rb +66 -0
  32. data/lib/martyr/runtime/data_set/element_common.rb +51 -0
  33. data/lib/martyr/runtime/data_set/element_locator.rb +143 -0
  34. data/lib/martyr/runtime/data_set/fact.rb +83 -0
  35. data/lib/martyr/runtime/data_set/fact_indexer.rb +72 -0
  36. data/lib/martyr/runtime/data_set/future_fact_value.rb +58 -0
  37. data/lib/martyr/runtime/data_set/future_metric.rb +40 -0
  38. data/lib/martyr/runtime/data_set/virtual_element.rb +131 -0
  39. data/lib/martyr/runtime/data_set/virtual_elements_builder.rb +202 -0
  40. data/lib/martyr/runtime/dimension_scopes/base_level_scope.rb +20 -0
  41. data/lib/martyr/runtime/dimension_scopes/degenerate_level_scope.rb +76 -0
  42. data/lib/martyr/runtime/dimension_scopes/dimension_scope_collection.rb +78 -0
  43. data/lib/martyr/runtime/dimension_scopes/level_scope_collection.rb +20 -0
  44. data/lib/martyr/runtime/dimension_scopes/query_level_scope.rb +223 -0
  45. data/lib/martyr/runtime/fact_scopes/base_fact_scope.rb +62 -0
  46. data/lib/martyr/runtime/fact_scopes/fact_scope_collection.rb +127 -0
  47. data/lib/martyr/runtime/fact_scopes/main_fact_scope.rb +7 -0
  48. data/lib/martyr/runtime/fact_scopes/null_scope.rb +7 -0
  49. data/lib/martyr/runtime/fact_scopes/sub_fact_scope.rb +16 -0
  50. data/lib/martyr/runtime/fact_scopes/wrapped_fact_scope.rb +11 -0
  51. data/lib/martyr/runtime/pivot/pivot_axis.rb +67 -0
  52. data/lib/martyr/runtime/pivot/pivot_cell.rb +54 -0
  53. data/lib/martyr/runtime/pivot/pivot_grain_element.rb +22 -0
  54. data/lib/martyr/runtime/pivot/pivot_row.rb +49 -0
  55. data/lib/martyr/runtime/pivot/pivot_table.rb +109 -0
  56. data/lib/martyr/runtime/pivot/pivot_table_builder.rb +125 -0
  57. data/lib/martyr/runtime/query/metric_dependency_resolver.rb +149 -0
  58. data/lib/martyr/runtime/query/query_context.rb +246 -0
  59. data/lib/martyr/runtime/query/query_context_builder.rb +215 -0
  60. data/lib/martyr/runtime/scope_operators/base_operator.rb +113 -0
  61. data/lib/martyr/runtime/scope_operators/group_operator.rb +18 -0
  62. data/lib/martyr/runtime/scope_operators/select_operator_for_dimension.rb +24 -0
  63. data/lib/martyr/runtime/scope_operators/select_operator_for_metric.rb +35 -0
  64. data/lib/martyr/runtime/scope_operators/where_operator_for_dimension.rb +43 -0
  65. data/lib/martyr/runtime/scope_operators/where_operator_for_metric.rb +25 -0
  66. data/lib/martyr/runtime/slices/data_slices/data_slice.rb +70 -0
  67. data/lib/martyr/runtime/slices/data_slices/metric_data_slice.rb +54 -0
  68. data/lib/martyr/runtime/slices/data_slices/plain_dimension_data_slice.rb +109 -0
  69. data/lib/martyr/runtime/slices/data_slices/time_dimension_data_slice.rb +9 -0
  70. data/lib/martyr/runtime/slices/has_scoped_levels.rb +29 -0
  71. data/lib/martyr/runtime/slices/memory_slices/TO_DELETE.md +188 -0
  72. data/lib/martyr/runtime/slices/memory_slices/memory_slice.rb +84 -0
  73. data/lib/martyr/runtime/slices/memory_slices/metric_memory_slice.rb +59 -0
  74. data/lib/martyr/runtime/slices/memory_slices/plain_dimension_memory_slice.rb +48 -0
  75. data/lib/martyr/runtime/slices/scopeable_slice_data.rb +73 -0
  76. data/lib/martyr/runtime/slices/slice_definitions/base_slice_definition.rb +30 -0
  77. data/lib/martyr/runtime/slices/slice_definitions/metric_slice_definition.rb +120 -0
  78. data/lib/martyr/runtime/slices/slice_definitions/plain_dimension_level_slice_definition.rb +26 -0
  79. data/lib/martyr/runtime/sub_cubes/fact_filler_strategies.rb +61 -0
  80. data/lib/martyr/runtime/sub_cubes/query_metrics.rb +56 -0
  81. data/lib/martyr/runtime/sub_cubes/sub_cube.rb +134 -0
  82. data/lib/martyr/runtime/sub_cubes/sub_cube_grain.rb +117 -0
  83. data/lib/martyr/schema/dimension_associations/dimension_association_collection.rb +33 -0
  84. data/lib/martyr/schema/dimension_associations/level_association.rb +37 -0
  85. data/lib/martyr/schema/dimension_associations/level_association_collection.rb +18 -0
  86. data/lib/martyr/schema/dimensions/dimension_definition_collection.rb +39 -0
  87. data/lib/martyr/schema/dimensions/plain_dimension_definition.rb +39 -0
  88. data/lib/martyr/schema/dimensions/time_dimension_definition.rb +24 -0
  89. data/lib/martyr/schema/facts/base_fact_definition.rb +22 -0
  90. data/lib/martyr/schema/facts/fact_definition_collection.rb +44 -0
  91. data/lib/martyr/schema/facts/main_fact_definition.rb +45 -0
  92. data/lib/martyr/schema/facts/sub_fact_definition.rb +44 -0
  93. data/lib/martyr/schema/metrics/base_metric.rb +77 -0
  94. data/lib/martyr/schema/metrics/built_in_metric.rb +38 -0
  95. data/lib/martyr/schema/metrics/count_distinct_metric.rb +172 -0
  96. data/lib/martyr/schema/metrics/custom_metric.rb +26 -0
  97. data/lib/martyr/schema/metrics/custom_rollup.rb +31 -0
  98. data/lib/martyr/schema/metrics/dependency_inferrer.rb +150 -0
  99. data/lib/martyr/schema/metrics/metric_definition_collection.rb +94 -0
  100. data/lib/martyr/schema/named_scopes/named_scope.rb +19 -0
  101. data/lib/martyr/schema/named_scopes/named_scope_collection.rb +42 -0
  102. data/lib/martyr/schema/plain_dimension_levels/base_level_definition.rb +39 -0
  103. data/lib/martyr/schema/plain_dimension_levels/degenerate_level_definition.rb +75 -0
  104. data/lib/martyr/schema/plain_dimension_levels/level_definition_collection.rb +15 -0
  105. data/lib/martyr/schema/plain_dimension_levels/query_level_definition.rb +99 -0
  106. data/lib/martyr/version.rb +3 -0
  107. data/lib/martyr/virtual_cube.rb +74 -0
  108. data/lib/martyr.rb +55 -0
  109. data/martyr.gemspec +41 -0
  110. metadata +296 -0
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.2
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in martyr.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Amit Aharoni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # Martyr
2
+ A multi-dimensional semantic layer on top of ActiveRecord that allows running pivot table queries and rendering
3
+ them as CSV, HTML, or KickChart-ready hashes. Supports time dimensions, cohort analysis, custom rollups, and drilling
4
+ through to the underlying ActiveRecord objects.
5
+
6
+ Designed predominantly to allow staff members and site owners to slice and dice data and easily produce reports.
7
+
8
+ Warning:
9
+ This gem is not production ready.
10
+
11
+ More to come...
12
+
13
+ ## The problem
14
+ The following simple queries demonstrate a core issue with relational data:
15
+
16
+ ```ruby
17
+ # Fetch count of all posts that were created 3 days ago
18
+ Post.where('created_at > ?', 3.days.ago)
19
+
20
+ # Fetch count of all posts that were created by the author whose email is me@domain.com
21
+ Post.joins(:author).where('authors.email' => 'me@domain.com')
22
+ ```
23
+ These two queries represent similar requests from the perspective of a human user. However, fulfilling these requests
24
+ requires knowledge about the structure of the database. This limits the ability to provide "free form" slicing and dicing of data.
25
+
26
+ Compare these queries with Martyr standard query interface:
27
+ ```ruby
28
+ # Fetch count of all posts that were created 3 days ago
29
+ PostCube.slice('post.created_at', gt: 3.days.ago)
30
+
31
+ # Fetch count of all posts that were created by the author whose email is me@domain.com
32
+ PostCube.slice('author.email', with: 'me@domain.com')
33
+
34
+ # As a compound hash format:
35
+ PostCube.slice('author.email' => {with: 'me@domain.com'}, 'post.created_at' => {gt: 3.days.ago})
36
+ ```
37
+ The last example demonstrates an important aspect of Martyr. A simple standard hash - which can be easily
38
+ serialized over HTTP requests - represents a slice of data that can be further manipulated. This means you can have
39
+ one standard controller action for displaying tables and graphs of any data you like.
40
+
41
+ Martyr keeps track of the slice of every element you query:
42
+
43
+ ```ruby
44
+ pivot_table = PostCube.slice('post.created_at', gt: 3.days.ago).pivot.on_columns('post_count').on_rows('authors.email')
45
+ first_row = pivot_table.rows.first
46
+ cell = first_row.cell_at['post_count']
47
+ cell.coordinates
48
+ # => { 'metric' => 'posts_cube.post_count', 'post.created_at' => {gt: 3.days.ago}, 'authors.email' => {with: 'first_row@domain.com'} }
49
+ ```
50
+
51
+ So an ERB partial that renders this cell as a link to another report - number of comments per post-type for that particular author and created_at constraints - may look like this:
52
+ ```ERB
53
+ <% data = Base64.urlsafe_encode64 {current_slice: cell.coordinates,
54
+ on_rows: ['post.type'],
55
+ on_columns: ['comment.type'],
56
+ view: 'my_drill_down_report' }.to_json %>
57
+
58
+ <%= link_to cell.value, standard_report_action_path(data: data) %>
59
+ ```
60
+
61
+ And the standard (non-secure) controller action can be as simple as:
62
+ ```ruby
63
+ def standard_drill_down
64
+ hash = JSON.parse(Base64.urlsafe_decode64(params[:data]}
65
+ query = PostCube.slice(hash['current_slice']).granulate(hash['on_rows'] + hash['on_columns'])
66
+ @pivot_table = query.pivot.on_columns(hash['on_columns']).on_rows(hash['on_rows'])
67
+ render hash['view']
68
+ end
69
+ ```
70
+
71
+
72
+ The examples that follow refer to the open source Chinook database schema.
73
+
74
+ ## Semantic layer DSL
75
+
76
+ ```ruby
77
+ class SharedDimensions < Martyr::Cube
78
+ define_dimension :genres do
79
+ degenerate_level :name
80
+ end
81
+
82
+ define_dimension :media_types do
83
+ degenerate_level :name
84
+ end
85
+
86
+ define_dimension :customers do
87
+ degenerate_level :country
88
+ degenerate_level :state
89
+ degenerate_level :city
90
+ query_level :last_name, -> { Customer.all }, fact_key: 'customers.id'
91
+ end
92
+ end
93
+
94
+ class MyCube < SharedDimensions
95
+ has_dimension_level :genres, :name
96
+ has_dimension_level :media_types, :name
97
+ has_dimension_level :customers, :last_name
98
+
99
+ has_sum_metric :units_sold, 'SUM(invoice_lines.quantity)'
100
+ has_sum_metric :amount, 'SUM(invoice_lines.unit_price * invoice_lines.quantity)'
101
+
102
+ has_custom_metric :commission, ->(fact) { (fact['amount'] * 0.3) }
103
+
104
+ has_custom_rollup :avg_transaction, ->(fact_set) { fact_set['amount'] / fact_set['units_sold'] }
105
+ has_custom_rollup :usa_amount, ->(fact_set) { fact_set.locate('customers.country', with: 'USA')['amount'] }
106
+ has_custom_rollup :cross_country_avg_transaction, ->(fact_set) { fact_set.locate(reset: 'customers.*')['avg_transaction'] }
107
+ has_custom_rollup :usa_avg_transaction, ->(fact_set) { fact_set.locate('customers.country', with: 'USA')['avg_transaction'] }
108
+
109
+ main_query do
110
+ InvoiceLine.joins(track: [:genre, :media_type], invoice: :customer)
111
+ end
112
+ end
113
+ ```
114
+
115
+ ## Query interface
116
+
117
+ Use `#granulate` to perform group by:
118
+ ```ruby
119
+ q = MyCube.granulate('genres.name', 'customers.city').pivot.on_columns(:metrics).on_rows('genres.name', 'customers.city')
120
+ q.to_csv
121
+ q.to_chart # => Kick chart format
122
+ ```
123
+
124
+ Use `#select` to choose metrics and `#slice` to perform where:
125
+ ```ruby
126
+ MyCube.select('units_sold', 'amount').
127
+ granulate('genres.name', 'customers.city').
128
+ slice('customers.country', with: 'USA').
129
+ pivot.on_columns(:metrics).on_rows('genres.name', 'customers.city').to_csv
130
+ ```
131
+
132
+ The same thing can be done in stages:
133
+ ```ruby
134
+ query = MyCube.select('units_sold', 'amount').
135
+ granulate('genres.name', 'customers.city').
136
+ slice('customers.country', with: 'USA').build
137
+
138
+ query.pivot.on_columns(:metrics).on_rows('genres.name', 'customers.city').to_csv
139
+ ```
140
+
141
+ Which allows further slicing in memory - in the same standard way - rather than in SQL:
142
+ ```ruby
143
+ query = MyCube.select('units_sold', 'amount').granulate('genres.name', 'customers.city').build
144
+ usa_slice = query.slice('customers.country', with: 'USA')
145
+ france_slice = query.slice('customers.country', with: 'France')
146
+
147
+ # One DB query, two reports
148
+ usa_slice.pivot.on_columns('genres.name').on_rows('customers.city').in_cells('units_sold').to_csv
149
+ france_slice.pivot.on_columns('genres.name').on_rows(:metrics).to_csv
150
+ ```
151
+
152
+ ## Virtual cubes DSL
153
+
154
+ Virtual cubes allow referring to two or more cubes, each with different granularity, on the same report.
155
+ ```ruby
156
+ class MyOtherCube < SharedDimensions
157
+ define_dimension :playlists do
158
+ query_level :name, -> { Playlist.all }, label_key: 'name'
159
+ end
160
+
161
+ has_dimension_level :genres, :name
162
+ has_dimension_level :media_types, :name
163
+ has_dimension_level :playlists, :name, fact_key: 'playlists.id'
164
+
165
+ has_sum_metric :tracks_count, 'COUNT(playlists.id)'
166
+
167
+ main_query do
168
+ Playlist.joins(tracks: [:genre, :media_type])
169
+ end
170
+ end
171
+
172
+ class MyVirtualCube < Martyr::VirtualCube
173
+ use_cube 'MyCube'
174
+ use_cube 'MyOtherCube'
175
+ end
176
+ ```
177
+
178
+ ## Using virtual cubes
179
+
180
+ Usage is the same as regular cubes.
181
+ ```ruby
182
+ query = MyVirtualCube.granulate('genres.name').on_columns('genres.name').on_rows('my_other_cube.tracks_count', 'my_cube.units_sold')
183
+ ```
184
+
185
+
186
+
187
+
188
+
189
+
190
+
191
+ ## Installation
192
+
193
+ Add this line to your application's Gemfile:
194
+
195
+ ```ruby
196
+ gem 'martyr'
197
+ ```
198
+
199
+ And then execute:
200
+
201
+ $ bundle
202
+
203
+ Or install it yourself as:
204
+
205
+ $ gem install martyr
206
+
207
+ ## Usage
208
+
209
+ TODO: Write usage instructions here
210
+
211
+ ## Development
212
+
213
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/console` for an interactive prompt that will allow you to experiment.
214
+
215
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
216
+
217
+ ## Contributing
218
+
219
+ 1. Fork it ( https://github.com/[my-github-username]/martyr/fork )
220
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
221
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
222
+ 4. Push to the branch (`git push origin my-new-feature`)
223
+ 5. Create a new Pull Request
224
+
225
+
226
+ Limitations:
227
+ - If you want to connect degenerate levels directly to the fact, make sure that they are unique across the entire
228
+ dimension.
229
+
230
+ For example, consider a simple dimension 'customers.country', 'customers.city', 'customers.name', and that
231
+ 'customers.name' is a query level, and the other levels are degenerates.
232
+
233
+ Problem:
234
+ If the cube connects with 'customers.city', the grain is 'customers.city', and the slice is 'customers.city' => 'Dover',
235
+ When you run `facts.first['customers.country']` you may receive either US or UK (if Dover appears once for each in
236
+ the dimension).
237
+
238
+ The reason is that Martyr needs a query level in order to resolve higher degenerate levels that do not connect to
239
+ the fact. To do that, it maintains one "representative" 'customers.name' record for each degenerate 'customers.city'
240
+ value. It then asks this representative to fetch its 'customers.country', which could be either US or UK, depending
241
+ on the randomly chosen representative.
242
+
243
+ Workaround 1:
244
+ Connect 'customers.country' to the fact.
245
+ Martyr always prefer to take degenerates from the facts if they are available.
246
+
247
+ Workaround 2:
248
+ Connect 'customers.name' to the cube and add it to the grain.
249
+ Martyr will prefer to use the query level 'customers.name' when resolving for 'customers.country'.
250
+
251
+ Reproduce:
252
+ c = Customer.create first_name: 'Amit', last_name: 'Aharoni', city: 'Paris', country: 'Hungary', email: 'amit.sites@gmail.com'
253
+ i = Invoice.create customer_id: c.id, invoice_date: Time.now, billing_country: 'Hungary', billing_city: 'Paris', total: 100
254
+ InvoiceLine.create invoice_id: i.id, track_id: 1, unit_price: 1, quantity: 100
255
+
256
+ sub_cube1 = MartyrSpec::DegeneratesAndNoQueryLevel.granulate('customers.city', 'genres.name').slice('customers.city', with: 'Paris').build.sub_cubes.first
257
+ sub_cube2 = MartyrSpec::DegeneratesAndAllLevels.granulate('customers.city', 'genres.name').slice('customers.city', with: 'Paris').build.sub_cubes.first
258
+ sub_cube3 = MartyrSpec::NoHighLevels.granulate('customers.last_name', 'genres.name').slice('customers.city', with: 'Paris').build.sub_cubes.first
259
+
260
+ sub_cube1.elements(levels: ['customers.city', 'customers.country']).count
261
+ # => 1
262
+ sub_cube2.elements(levels: ['customers.city', 'customers.country']).count
263
+ # => 2
264
+ sub_cube3.elements(levels: ['customers.city', 'customers.country']).count
265
+ # => 2
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/TODO.txt ADDED
@@ -0,0 +1,54 @@
1
+
2
+ Add ability to add includes directive to an existing dimension scope.
3
+
4
+ Add ability to sort results
5
+
6
+ ============================
7
+
8
+ - QueryContext currently only supports one #slice operation. It should really return a decorator of itself that allies
9
+ the memory slice on it.
10
+
11
+ - The #for_cube_name method on both memory slices and data slices is dangerous - once the block returns the object's
12
+ scoping is useless. Create either one decorator for both, or a different decorator for each one that acts as a filter.
13
+
14
+ - Coordinate currently communicate with the memory slice in order to reset and set its values. This is useless, memory
15
+ slice should be immutable to the cells, otherwise custom rollups that reset dimension level on a memory slice 2:
16
+ <data slice 1> + <memory slice 2> will return a different value than the same custom rollup on data slice 3:
17
+ <data slice 3> = <data slice 1> + <memory slice 2> though they should be the same.
18
+
19
+ E.g., the rollup:
20
+ has_custom_rollup :usa_amount, ->(fact_set) { fact_set.locate('customers.country', with: 'USA')['amount'] }
21
+
22
+ Will have different result for these two:
23
+ Cube.slice('customers.country', with: 'France').build # => rollup will not be able to locate USA element
24
+ Cube.build.slice('customers.country', with: 'France') # => rollup will be able to locate USA element
25
+
26
+ - Virtual cube builder needs fixing. The #build method with all combinations is not working right. It should be
27
+ transitioned to something more performing that does not rely on all possible combinations.
28
+
29
+ - Coordinate, ElementLocator and FactIndexer all do things very similar. Why do we need ElementLocator when we have
30
+ FactIndexer? Does Coordinates really need to know about MemorySlice? Can ElementLocator have an #all method?
31
+
32
+
33
+ If memory slice is immutable, while FactIndexer holds all facts and is able to address multiple memory slices,
34
+ it makes sense to have an element locator that is cube-specific and memory-slice-specific, so the user does not need
35
+ to provide the memory slice every time a cell is needed.
36
+ In this case, the locator is injected to the element to perform location search operations.
37
+
38
+ Coordinates does not need to contain a memory slice. Any attempt to go outside the slice will yield an empty cell.
39
+ So it's simply a hash. We can further remove the knowledge of coordinates about what is a level and what is a
40
+ metric by having this be resolved by the ElementLocator (it has to know fact indexer anyways).
41
+
42
+ VirtualElement #locate will continue to be as today - delegating to the individual locators.
43
+
44
+ VirtualElementBuilder can be simplified - it can simply be provided with the locators, which now will know how to
45
+ fetch all elements if necessary. --> Although: locator should not hold the level ids.. elements grain is really
46
+ based on the pivot grain which can be different than the sub cube grain, and can change dynamically based on whether
47
+ totals are calculated. So perhaps it's better to have the locator be unrelated to the #elements operation.
48
+ Actually, it can be injected into elements instead of memory slice!
49
+
50
+ Standardization of metric names can be moved to the locator too, so "dirty" user input is handled in one place.
51
+
52
+ - Due to the way the grain is initialized in cubes, I'm not sure if currently a virtual cube containing 2 cubes one
53
+ of which contains one more detailed level of granularity is going to work (select_supported_level_ids selects
54
+ all levels in supported dimensions, regardless of whether the level itself can actually be fetched)
data/bin/console ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "martyr"
5
+ require "chinook_database"
6
+ ChinookDatabase.connect
7
+ require_relative "../spec/models/spec_models"
8
+
9
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
10
+
11
+ module Martyr
12
+ def self.reload!
13
+ Reloader.new(self).reload
14
+ end
15
+
16
+ class Reloader
17
+ def initialize(top)
18
+ @top = top
19
+ end
20
+
21
+ def reload
22
+ cleanup
23
+ load_all
24
+ end
25
+
26
+ private
27
+
28
+ def all_project_objects_lookup
29
+ @_all_project_objects_lookup ||= Hash[all_project_objects.map{|x| [x, true]}]
30
+ end
31
+
32
+ def all_project_objects(current = @top)
33
+ return [] unless current.is_a?(Module) and current.to_s.split('::').first == @top.to_s
34
+ [current] + current.constants.flat_map{|x| all_project_objects(current.const_get(x))}
35
+ end
36
+
37
+ def cleanup(parent = Object, current = @top)
38
+ return unless all_project_objects_lookup[current]
39
+ current.constants.each {|const| cleanup current, current.const_get(const)}
40
+ parent.send(:remove_const, current.to_s.split('::').last.to_sym)
41
+ end
42
+
43
+ def loaded_files
44
+ $LOADED_FEATURES.select{|x| x.starts_with?(File.expand_path('../../lib/martyr'))}
45
+ end
46
+
47
+ def load_all
48
+ loaded_files.each{|x| load x}
49
+ true
50
+ end
51
+ end
52
+ end
53
+
54
+ def reload!
55
+ Martyr.reload!
56
+ end
57
+
58
+ require "pry"
59
+ Pry.start
60
+
61
+ # require "irb"
62
+ # IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,73 @@
1
+ module Martyr
2
+ class BaseCube
3
+ extend Martyr::Translations
4
+
5
+ def self.set_cube_name(value)
6
+ @name = value.to_s
7
+ end
8
+
9
+ def self.cube_name
10
+ @name || name.split('::').last.underscore
11
+ end
12
+
13
+ def self.contained_cube_classes
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def self.new_query_context_builder
18
+ Runtime::QueryContextBuilder.new(self, @named_scopes.try(:query_helper_module))
19
+ end
20
+
21
+ # @return [NamedScopedCollection] and extends for the first time the helper module used to delegate scope names to
22
+ # a new query context builder
23
+ def self.named_scopes
24
+ return @named_scopes if @named_scopes
25
+ @named_scopes = Schema::NamedScopeCollection.new
26
+ extend(@named_scopes.cube_helper_module)
27
+ @named_scopes
28
+ end
29
+
30
+ class << self
31
+ delegate :scope, to: :named_scopes
32
+ end
33
+
34
+ def self.dimension_definitions
35
+ {}
36
+ end
37
+
38
+ def self.supported_dimension_definitions
39
+ raise NotImplementedError
40
+ end
41
+
42
+ # @param dimension_names
43
+ # @return [Runtime::DimensionScopeCollection] a collection with dimensions that maintain SQL scoped queries and
44
+ # their results. All dimensions that have at least one level supported by the cube are included.
45
+ def self.build_dimension_scopes(dimension_names)
46
+ dimension_scopes = Runtime::DimensionScopeCollection.new(dimension_definitions)
47
+ supported_dimension_definitions.slice(*dimension_names).values.flat_map(&:level_objects).each do |level|
48
+ dimension_scopes.register_level(level)
49
+ end
50
+ dimension_scopes
51
+ end
52
+
53
+ def self.find_metric(metric_name)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # @param metric_id [String] fully qualified metric ID
58
+ # @return [MetricDefinition]
59
+ def self.find_metric_id(metric_id)
60
+ cube_name, metric_name = id_components(metric_id)
61
+ raise(Schema::Error.new "Metric for cube `#{cube_name}` was asked for cube `#{self.cube_name}`") unless
62
+ cube_name == self.cube_name
63
+
64
+ find_metric(metric_name)
65
+ end
66
+
67
+ # @param metric_id [String] fully qualified metric ID
68
+ # @return [Boolean]
69
+ def self.metric?(metric_id)
70
+ find_metric_id(metric_id).present? rescue false
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,134 @@
1
+ module Martyr
2
+ class Cube < BaseCube
3
+ extend Martyr::LevelComparator
4
+
5
+ def self.contained_cube_classes
6
+ [self]
7
+ end
8
+
9
+ def self.fact_definitions
10
+ @fact_definitions ||= Schema::FactDefinitionCollection.new(self)
11
+ end
12
+
13
+ # Return all dimension definitions regardless of whether they are supported by the cube.
14
+ # A cube +supports+ a dimension only if it calls the #has_dimension_level DSL method.
15
+ # It is quite typical to have multiple cubes inherenting from a context with shared dimensions.
16
+ # So it is typical to have cubes that maintain a reference to a dimension definition they do not support.
17
+ #
18
+ # @see #supported_dimension_definitions to get only the supported dimensions.
19
+ #
20
+ # In the example below, Cube1 does not support shared_dimension_2:
21
+ #
22
+ # class Common < Martyr::Cube
23
+ # define_dimension :shared_dimension_1 do
24
+ # ...
25
+ # end
26
+ #
27
+ # define_dimension :shared_dimension_2 do
28
+ # ...
29
+ # end
30
+ # end
31
+ #
32
+ # class Cube1 < Common
33
+ # has_dimension_level :shared_dimension_1, :level1
34
+ # end
35
+ #
36
+ # Cube1.dimension_definitions.keys
37
+ # # => ['shared_dimension_1', 'shared_dimension_2']
38
+ #
39
+ # @return [Schema::DimensionDefinitionCollection]
40
+ def self.dimension_definitions
41
+ return @dimension_definitions if @dimension_definitions
42
+ @dimension_definitions = Schema::DimensionDefinitionCollection.new
43
+ @dimension_definitions.merge! parent_schema_class.dimension_definitions if parent_schema_class.present?
44
+ @dimension_definitions
45
+ end
46
+
47
+ def self.set_default_fact_grain(*level_ids_arr)
48
+ @default_fact_grain = level_ids_arr
49
+ end
50
+
51
+ def self.default_fact_grain
52
+ @default_fact_grain || []
53
+ end
54
+
55
+ # @return [Array<LevelAssociation>]
56
+ def self.default_fact_grain_level_associations
57
+ level_association_lookup = level_associations.index_by(&:id)
58
+ default_fact_grain.map do |x|
59
+ level_association_lookup[x] || raise(Schema::Error.new("`#{x}` is in the default fact grain but not connected to the fact query"))
60
+ end
61
+ end
62
+
63
+ class << self
64
+ delegate :define_dimension, to: :dimension_definitions
65
+ delegate :main_fact, :build_fact_scopes, :sub_query, to: :fact_definitions
66
+ delegate :has_dimension_level, :has_count_distinct_metric, :has_min_metric, :has_max_metric, # DSL
67
+ :has_sum_metric, :has_custom_metric, :has_custom_rollup, :main_query, # DSL
68
+ :metrics, :find_metric, :dimension_associations, to: :main_fact # Runtime
69
+
70
+ delegate :select, :slice, :granulate, :pivot, :build, to: :new_query_context_builder
71
+ alias_method :all, :new_query_context_builder
72
+ end
73
+
74
+ def self.martyr_schema_class?
75
+ true
76
+ end
77
+
78
+ def self.virtual?
79
+ false
80
+ end
81
+
82
+ # @return [nil, Base]
83
+ def self.parent_schema_class
84
+ ancestors[1..-1].find { |x| x != self and x.respond_to?(:martyr_schema_class?) }
85
+ end
86
+
87
+ # @see comment for #dimension_definitions
88
+ #
89
+ # @return [Schema::DimensionDefinitionCollection] including dimensions that have at least one level
90
+ # supported by the cube through #has_dimension_level
91
+ def self.supported_dimension_definitions
92
+ dimension_definitions.slice(*dimension_associations.keys)
93
+ end
94
+
95
+ # Return all levels that are directly connected to the cube with #has_dimension_level.
96
+ #
97
+ # @return [Array<LevelAssociation>]
98
+ def self.level_associations
99
+ dimension_associations.flat_map { |_name, dimension_association| dimension_association.level_objects }
100
+ end
101
+
102
+ # @return [Array<BaseLevelDefinition>]
103
+ def self.supported_level_definitions
104
+ lowest_level_of(level_associations).flat_map do |level_association|
105
+ level_association.level_definition.level_and_above
106
+ end
107
+ end
108
+
109
+ # @return [Array<String>]
110
+ def self.supported_level_ids
111
+ supported_level_definitions.map(&:id)
112
+ end
113
+
114
+ # Helper methods used to filter out unsupported levels.
115
+ #
116
+ # @param level_ids [Array<String>]
117
+ # @return [Array<String>] all ids that are supported by the cube through the dimension
118
+ def self.select_supported_level_ids(level_ids)
119
+ level_ids = Array.wrap(level_ids)
120
+ unsupported = level_ids - supported_level_ids
121
+ level_ids - unsupported
122
+ end
123
+
124
+ # @return [Schema::DependencyInferrer]
125
+ def self.metric_dependency_inferrer
126
+ @metric_dependency_inferrer ||= Schema::DependencyInferrer.new.add_cube_levels(self)
127
+ end
128
+
129
+ def self.standardizer
130
+ @standardizer ||= Martyr::MetricIdStandardizer.new(cube_name, raise_if_not_ok: false)
131
+ end
132
+
133
+ end
134
+ end
@@ -0,0 +1,26 @@
1
+ module Martyr
2
+ class DimensionReference
3
+ attr_reader :dimension_definition
4
+ delegate :name, to: :dimension_definition
5
+
6
+ include Martyr::HasLevelCollection
7
+
8
+ delegate :lowest_level, :level_above, :find_level, :level_names, :level_objects, :has_level?,
9
+ :has_dimension_level, :register_level, to: :levels
10
+
11
+ # For reflection
12
+ def dimension?
13
+ true
14
+ end
15
+
16
+ def initialize(dimension_definition, levels_collection_class, &block)
17
+ @dimension_definition = dimension_definition
18
+ @levels = levels_collection_class.new(dimension: self, &block)
19
+ end
20
+
21
+ # @param mod [Module]
22
+ def register_element_helper_methods(mod)
23
+ level_objects.each {|l| l.register_element_helper_methods(mod)}
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ module Martyr
2
+ class Error < StandardError
3
+ end
4
+
5
+ module Internal
6
+ class Error < ::Martyr::Error
7
+ end
8
+ end
9
+
10
+ module Schema
11
+ class Error < ::Martyr::Error
12
+ end
13
+ end
14
+
15
+ module Query
16
+ class Error < ::Martyr::Error
17
+ end
18
+ end
19
+
20
+ end