martyr 0.1.74.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.tags +868 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +265 -0
- data/Rakefile +1 -0
- data/TODO.txt +54 -0
- data/bin/console +62 -0
- data/bin/setup +7 -0
- data/lib/martyr/base_cube.rb +73 -0
- data/lib/martyr/cube.rb +134 -0
- data/lib/martyr/dimension_reference.rb +26 -0
- data/lib/martyr/errors.rb +20 -0
- data/lib/martyr/helpers/delegators.rb +17 -0
- data/lib/martyr/helpers/intervals.rb +222 -0
- data/lib/martyr/helpers/metric_id_standardizer.rb +47 -0
- data/lib/martyr/helpers/registrable.rb +15 -0
- data/lib/martyr/helpers/sorter.rb +79 -0
- data/lib/martyr/helpers/translations.rb +34 -0
- data/lib/martyr/level_concern/has_level_collection.rb +11 -0
- data/lib/martyr/level_concern/level.rb +45 -0
- data/lib/martyr/level_concern/level_collection.rb +60 -0
- data/lib/martyr/level_concern/level_comparator.rb +45 -0
- data/lib/martyr/level_concern/level_definitions_by_dimension.rb +24 -0
- data/lib/martyr/runtime/data_set/coordinates.rb +108 -0
- data/lib/martyr/runtime/data_set/element.rb +66 -0
- data/lib/martyr/runtime/data_set/element_common.rb +51 -0
- data/lib/martyr/runtime/data_set/element_locator.rb +143 -0
- data/lib/martyr/runtime/data_set/fact.rb +83 -0
- data/lib/martyr/runtime/data_set/fact_indexer.rb +72 -0
- data/lib/martyr/runtime/data_set/future_fact_value.rb +58 -0
- data/lib/martyr/runtime/data_set/future_metric.rb +40 -0
- data/lib/martyr/runtime/data_set/virtual_element.rb +131 -0
- data/lib/martyr/runtime/data_set/virtual_elements_builder.rb +202 -0
- data/lib/martyr/runtime/dimension_scopes/base_level_scope.rb +20 -0
- data/lib/martyr/runtime/dimension_scopes/degenerate_level_scope.rb +76 -0
- data/lib/martyr/runtime/dimension_scopes/dimension_scope_collection.rb +78 -0
- data/lib/martyr/runtime/dimension_scopes/level_scope_collection.rb +20 -0
- data/lib/martyr/runtime/dimension_scopes/query_level_scope.rb +223 -0
- data/lib/martyr/runtime/fact_scopes/base_fact_scope.rb +62 -0
- data/lib/martyr/runtime/fact_scopes/fact_scope_collection.rb +127 -0
- data/lib/martyr/runtime/fact_scopes/main_fact_scope.rb +7 -0
- data/lib/martyr/runtime/fact_scopes/null_scope.rb +7 -0
- data/lib/martyr/runtime/fact_scopes/sub_fact_scope.rb +16 -0
- data/lib/martyr/runtime/fact_scopes/wrapped_fact_scope.rb +11 -0
- data/lib/martyr/runtime/pivot/pivot_axis.rb +67 -0
- data/lib/martyr/runtime/pivot/pivot_cell.rb +54 -0
- data/lib/martyr/runtime/pivot/pivot_grain_element.rb +22 -0
- data/lib/martyr/runtime/pivot/pivot_row.rb +49 -0
- data/lib/martyr/runtime/pivot/pivot_table.rb +109 -0
- data/lib/martyr/runtime/pivot/pivot_table_builder.rb +125 -0
- data/lib/martyr/runtime/query/metric_dependency_resolver.rb +149 -0
- data/lib/martyr/runtime/query/query_context.rb +246 -0
- data/lib/martyr/runtime/query/query_context_builder.rb +215 -0
- data/lib/martyr/runtime/scope_operators/base_operator.rb +113 -0
- data/lib/martyr/runtime/scope_operators/group_operator.rb +18 -0
- data/lib/martyr/runtime/scope_operators/select_operator_for_dimension.rb +24 -0
- data/lib/martyr/runtime/scope_operators/select_operator_for_metric.rb +35 -0
- data/lib/martyr/runtime/scope_operators/where_operator_for_dimension.rb +43 -0
- data/lib/martyr/runtime/scope_operators/where_operator_for_metric.rb +25 -0
- data/lib/martyr/runtime/slices/data_slices/data_slice.rb +70 -0
- data/lib/martyr/runtime/slices/data_slices/metric_data_slice.rb +54 -0
- data/lib/martyr/runtime/slices/data_slices/plain_dimension_data_slice.rb +109 -0
- data/lib/martyr/runtime/slices/data_slices/time_dimension_data_slice.rb +9 -0
- data/lib/martyr/runtime/slices/has_scoped_levels.rb +29 -0
- data/lib/martyr/runtime/slices/memory_slices/TO_DELETE.md +188 -0
- data/lib/martyr/runtime/slices/memory_slices/memory_slice.rb +84 -0
- data/lib/martyr/runtime/slices/memory_slices/metric_memory_slice.rb +59 -0
- data/lib/martyr/runtime/slices/memory_slices/plain_dimension_memory_slice.rb +48 -0
- data/lib/martyr/runtime/slices/scopeable_slice_data.rb +73 -0
- data/lib/martyr/runtime/slices/slice_definitions/base_slice_definition.rb +30 -0
- data/lib/martyr/runtime/slices/slice_definitions/metric_slice_definition.rb +120 -0
- data/lib/martyr/runtime/slices/slice_definitions/plain_dimension_level_slice_definition.rb +26 -0
- data/lib/martyr/runtime/sub_cubes/fact_filler_strategies.rb +61 -0
- data/lib/martyr/runtime/sub_cubes/query_metrics.rb +56 -0
- data/lib/martyr/runtime/sub_cubes/sub_cube.rb +134 -0
- data/lib/martyr/runtime/sub_cubes/sub_cube_grain.rb +117 -0
- data/lib/martyr/schema/dimension_associations/dimension_association_collection.rb +33 -0
- data/lib/martyr/schema/dimension_associations/level_association.rb +37 -0
- data/lib/martyr/schema/dimension_associations/level_association_collection.rb +18 -0
- data/lib/martyr/schema/dimensions/dimension_definition_collection.rb +39 -0
- data/lib/martyr/schema/dimensions/plain_dimension_definition.rb +39 -0
- data/lib/martyr/schema/dimensions/time_dimension_definition.rb +24 -0
- data/lib/martyr/schema/facts/base_fact_definition.rb +22 -0
- data/lib/martyr/schema/facts/fact_definition_collection.rb +44 -0
- data/lib/martyr/schema/facts/main_fact_definition.rb +45 -0
- data/lib/martyr/schema/facts/sub_fact_definition.rb +44 -0
- data/lib/martyr/schema/metrics/base_metric.rb +77 -0
- data/lib/martyr/schema/metrics/built_in_metric.rb +38 -0
- data/lib/martyr/schema/metrics/count_distinct_metric.rb +172 -0
- data/lib/martyr/schema/metrics/custom_metric.rb +26 -0
- data/lib/martyr/schema/metrics/custom_rollup.rb +31 -0
- data/lib/martyr/schema/metrics/dependency_inferrer.rb +150 -0
- data/lib/martyr/schema/metrics/metric_definition_collection.rb +94 -0
- data/lib/martyr/schema/named_scopes/named_scope.rb +19 -0
- data/lib/martyr/schema/named_scopes/named_scope_collection.rb +42 -0
- data/lib/martyr/schema/plain_dimension_levels/base_level_definition.rb +39 -0
- data/lib/martyr/schema/plain_dimension_levels/degenerate_level_definition.rb +75 -0
- data/lib/martyr/schema/plain_dimension_levels/level_definition_collection.rb +15 -0
- data/lib/martyr/schema/plain_dimension_levels/query_level_definition.rb +99 -0
- data/lib/martyr/version.rb +3 -0
- data/lib/martyr/virtual_cube.rb +74 -0
- data/lib/martyr.rb +55 -0
- data/martyr.gemspec +41 -0
- metadata +296 -0
data/.travis.yml
ADDED
data/Gemfile
ADDED
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,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
|
data/lib/martyr/cube.rb
ADDED
@@ -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
|