martyr 0.1.74.pre
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.
- 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
|