├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── activecube.gemspec ├── bin ├── console └── setup ├── lib ├── activecube.rb └── activecube │ ├── active_record_extension.rb │ ├── base.rb │ ├── common │ └── metrics.rb │ ├── cube_definition.rb │ ├── definition_methods.rb │ ├── dimension.rb │ ├── field.rb │ ├── input_argument_error.rb │ ├── metric.rb │ ├── modifier.rb │ ├── processor │ ├── composer.rb │ ├── index.rb │ ├── measure_tables.rb │ ├── optimizer.rb │ ├── table.rb │ └── template.rb │ ├── query │ ├── chain_appender.rb │ ├── cube_query.rb │ ├── item.rb │ ├── limit.rb │ ├── limit_by.rb │ ├── measure.rb │ ├── measure_nothing.rb │ ├── modification.rb │ ├── option.rb │ ├── ordering.rb │ ├── selector.rb │ └── slice.rb │ ├── query_methods.rb │ ├── selector.rb │ ├── version.rb │ ├── view.rb │ ├── view_connection.rb │ └── view_definition.rb └── spec ├── cases └── activecube_spec.rb ├── migrations ├── 1_create_transfers_currency_table.rb ├── 2_create_transfers_from_table.rb └── 3_create_transfers_to_table.rb ├── models ├── application_record.rb ├── dimension │ ├── currency.rb │ ├── date.rb │ └── date_field.rb ├── metric │ ├── amount.rb │ └── count.rb ├── query_helper.rb └── test │ ├── currency_selector.rb │ ├── date_selector.rb │ ├── transfer_from_selector.rb │ ├── transfer_to_selector.rb │ ├── transfers_cube.rb │ ├── transfers_currency.rb │ ├── transfers_from.rb │ └── transfers_to.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This is the configuration used to check the rubocop source code. 2 | 3 | require: 4 | - rubocop-performance 5 | 6 | AllCops: 7 | Exclude: 8 | - bin/**/* 9 | DisplayStyleGuide: true 10 | NewCops: enable 11 | 12 | # *********************** Bundler ************************** 13 | # https://docs.rubocop.org/rubocop/cops_bundler.html 14 | 15 | Bundler/OrderedGems: 16 | Description: >- 17 | Gems within groups in the Gemfile should be alphabetically sorted. 18 | Enabled: true 19 | ConsiderPunctuation: true 20 | 21 | # *********************** Layouts ************************** 22 | # https://docs.rubocop.org/rubocop/cops_layout.html 23 | 24 | Layout/ClassStructure: 25 | Description: "Enforces a configured order of definitions within a class body." 26 | StyleGuide: "#consistent-classes" 27 | Enabled: true 28 | 29 | Layout/LineLength: 30 | Description: "Checks that line length does not exceed the configured limit." 31 | AutoCorrect: true # this is false by default 32 | Exclude: 33 | - Gemfile 34 | 35 | # *********************** Metrics ************************** 36 | # https://docs.rubocop.org/rubocop/1.5/cops_metrics.html 37 | 38 | Metrics/BlockLength: 39 | Description: 'Avoid long blocks with many lines.' 40 | Enabled: true 41 | Exclude: 42 | - 'spec/**/*' 43 | 44 | # *********************** Styles *************************** 45 | # https://docs.rubocop.org/rubocop/cops_style.html 46 | 47 | Style/Documentation: 48 | Description: >- 49 | This cop checks for missing top-level documentation of classes and modules. 50 | Enabled: false 51 | 52 | Style/FrozenStringLiteralComment: 53 | Description: >- 54 | Add the frozen_string_literal comment to the top of files 55 | to help transition to frozen string literals by default. 56 | Enabled: true 57 | EnforcedStyle: never 58 | 59 | Style/HashSyntax: 60 | Description: >- 61 | Prefer Ruby 1.9 hash syntax { a: 1, b: 2 } over 1.8 syntax 62 | { :a => 1, :b => 2 }. 63 | StyleGuide: "#hash-literals" 64 | Enabled: true 65 | EnforcedStyle: no_mixed_keys 66 | 67 | Style/RedundantInterpolation: 68 | Description: >- 69 | This cop checks for strings that are just an interpolated expression. 70 | Enabled: false 71 | 72 | Style/StringLiterals: 73 | Description: "Checks if uses of quotes match the configured preference." 74 | StyleGuide: "#consistent-string-literals" 75 | Enabled: true 76 | ConsistentQuotesInMultiline: true 77 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.3 7 | before_install: gem install bundler -v 1.17.3 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at astudnev@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in activecube.gemspec 6 | gemspec 7 | 8 | group :development do 9 | gem 'rubocop', '~> 1.38' 10 | gem 'rubocop-performance', '~> 1.15' 11 | end 12 | 13 | group :test do 14 | gem 'clickhouse-activerecord', git: 'https://github.com/bitquery/clickhouse-activerecord.git' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/bitquery/clickhouse-activerecord.git 3 | revision: 7d638fee3548d020a8111636a26af1e7f29cf4b5 4 | specs: 5 | clickhouse-activerecord (0.3.9) 6 | activerecord (>= 5.2) 7 | bundler (>= 1.13.4) 8 | 9 | PATH 10 | remote: . 11 | specs: 12 | activecube (0.1.53) 13 | activerecord (>= 5.2) 14 | 15 | GEM 16 | remote: https://rubygems.org/ 17 | specs: 18 | activemodel (6.0.2.1) 19 | activesupport (= 6.0.2.1) 20 | activerecord (6.0.2.1) 21 | activemodel (= 6.0.2.1) 22 | activesupport (= 6.0.2.1) 23 | activesupport (6.0.2.1) 24 | concurrent-ruby (~> 1.0, >= 1.0.2) 25 | i18n (>= 0.7, < 2) 26 | minitest (~> 5.1) 27 | tzinfo (~> 1.1) 28 | zeitwerk (~> 2.2) 29 | ast (2.4.2) 30 | concurrent-ruby (1.1.6) 31 | diff-lcs (1.3) 32 | i18n (1.8.2) 33 | concurrent-ruby (~> 1.0) 34 | json (2.6.2) 35 | minitest (5.14.0) 36 | parallel (1.22.1) 37 | parser (3.1.2.1) 38 | ast (~> 2.4.1) 39 | rainbow (3.1.1) 40 | rake (13.0.1) 41 | regexp_parser (2.6.0) 42 | rexml (3.2.5) 43 | rspec (3.9.0) 44 | rspec-core (~> 3.9.0) 45 | rspec-expectations (~> 3.9.0) 46 | rspec-mocks (~> 3.9.0) 47 | rspec-core (3.9.1) 48 | rspec-support (~> 3.9.1) 49 | rspec-expectations (3.9.0) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.9.0) 52 | rspec-mocks (3.9.1) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.9.0) 55 | rspec-support (3.9.2) 56 | rubocop (1.38.0) 57 | json (~> 2.3) 58 | parallel (~> 1.10) 59 | parser (>= 3.1.2.1) 60 | rainbow (>= 2.2.2, < 4.0) 61 | regexp_parser (>= 1.8, < 3.0) 62 | rexml (>= 3.2.5, < 4.0) 63 | rubocop-ast (>= 1.23.0, < 2.0) 64 | ruby-progressbar (~> 1.7) 65 | unicode-display_width (>= 1.4.0, < 3.0) 66 | rubocop-ast (1.23.0) 67 | parser (>= 3.1.1.0) 68 | rubocop-performance (1.15.0) 69 | rubocop (>= 1.7.0, < 2.0) 70 | rubocop-ast (>= 0.4.0) 71 | ruby-progressbar (1.11.0) 72 | thread_safe (0.3.6) 73 | tzinfo (1.2.6) 74 | thread_safe (~> 0.1) 75 | unicode-display_width (2.3.0) 76 | zeitwerk (2.2.2) 77 | 78 | PLATFORMS 79 | ruby 80 | 81 | DEPENDENCIES 82 | activecube! 83 | bundler (~> 1.17) 84 | clickhouse-activerecord! 85 | rake (>= 12.3.3) 86 | rspec (~> 3.0) 87 | rubocop (~> 1.38) 88 | rubocop-performance (~> 1.15) 89 | 90 | BUNDLED WITH 91 | 1.17.3 92 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 astudnev 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Activecube: Multi-Dimensional Queries with Rails 2 | 3 | Activecube is the library to make multi-dimensional queries to data warehouse, such as: 4 | 5 | ```ruby 6 | Cube.slice( 7 | date: cube.dimensions[:date][:date].format('%Y-%m'), 8 | currency: cube.dimensions[:currency][:symbol] 9 | ).measure(:count). 10 | when(cube.selectors[:currency].in('USD','EUR')).to_sql 11 | ``` 12 | 13 | Cube, dimensions, metrics and selectors are defined in the Model, similary to 14 | ActiveRecord. 15 | 16 | Activecube uses Rails ActiveRecord in implementation. 17 | 18 | In particular, you have to define all tables, used in 19 | Activecube, as ActiveRecord tables. 20 | 21 | 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'activecube' 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | 35 | Or install it yourself as: 36 | 37 | $ gem install activecube 38 | 39 | ## Usage 40 | 41 | Basic steps to use ActiveCube are: 42 | 43 | 1. Define your database schema in models, as you do with Rails 44 | 2. Setup connection properties to data warehouse in config/database.yml. You can use multiple connections 45 | if you use Rails 6 or higher 46 | 3. Define cubes in models, sub-classed from Activecube::Base. Look 47 | [spec/models/test/transfers_cube.rb](spec/models/test/transfers_cube.rb) as example 48 | 4. Make queries to the cubes 49 | 50 | Check [spec/cases/activecube_spec.rb](spec/cases/activecube_spec.rb) for more examples. 51 | 52 | 53 | ### Cube definition 54 | 55 | Cube defined using the following attributes: 56 | 57 | - **table** specifies, which physical database tables can be considered to query 58 | ```ruby 59 | table TransfersCurrency 60 | table TransfersFrom 61 | table TransfersTo 62 | ``` 63 | 64 | - **dimension** specifies classes used for slicing the cube 65 | 66 | ```ruby 67 | dimension date: Dimension::Date, 68 | currency: Dimension::Currency 69 | ``` 70 | 71 | Or 72 | 73 | ```ruby 74 | dimension date: Dimension::Date 75 | dimension currency: Dimension::Currency 76 | ``` 77 | 78 | - **metric** specifies which results expected from the cube queries 79 | 80 | ```ruby 81 | metric amount: Metric::Amount, 82 | count: Metric::Count 83 | ``` 84 | 85 | Or 86 | 87 | ```ruby 88 | metric amount: Metric::Amount 89 | metric count: Metric::Count 90 | ``` 91 | 92 | - **selector** is a set of expressions, which can filter results 93 | 94 | ```ruby 95 | selector currency: CurrencySelector, 96 | transfer_from: TransferFromSelector, 97 | transfer_to: TransferToSelector 98 | ``` 99 | 100 | ### Table definition 101 | 102 | Tables are defined as regular active records, with additional optional attribute 'index': 103 | ```ruby 104 | index 'currency_id', cardinality: 4 105 | ``` 106 | 107 | which means that the table has an index onm currency_id field, with average number of different entries 108 | of 10,000 ( 10^4). This creates a hint for optimizer to build queries. 109 | 110 | Indexes can span multiple fields, as 111 | 112 | ```ruby 113 | index ['currency_id','date'], cardinality: 6 114 | ``` 115 | 116 | Note, that if you created combined index in database, you most probable will need to define all 117 | indexed combinations, for example: 118 | 119 | ```ruby 120 | index ['currency_id'], cardinality: 4 121 | index ['currency_id','date'], cardinality: 6 122 | ``` 123 | 124 | You can require using index in some cases. If required: true added, the table will be used **only** in case when this field is used 125 | in query metric, dimension or selector. 126 | ```ruby 127 | index ['currency_id'], cardinality: 4, required: true 128 | ``` 129 | 130 | ### Query language 131 | 132 | You use the cube class to create and execute queries. 133 | 134 | Queries can be expressed as Arel query, SQL or executed against the database, returning results. 135 | 136 | The methods used to contruct the query: 137 | 138 | - **slice** defines which dimensions slices the results 139 | - **measure** defines what to measure 140 | - **when** defines which selectors to apply 141 | - **desc, asc, offset, limit** are for ordering and limiting result set 142 | 143 | After the query contructed, the following methods can be applied: 144 | 145 | - **to_sql** to generate String SQL query from cube query 146 | - **to_query** to generate Arel query 147 | - **query** to execute query and return ResultSet 148 | 149 | ### Managing Connections 150 | 151 | 152 | You can control the connection used to construct and execute query by 153 | ActiveRecord standard API: 154 | 155 | ```ruby 156 | ApplicationRecord.connected_to(database: :data_warehouse) do 157 | cube = My::TransfersCube 158 | cube.slice( 159 | date: cube.dimensions[:date][:date].format('%Y-%m'), 160 | currency: cube.dimensions[:currency][:symbol] 161 | ).measure(:count).query 162 | end 163 | ``` 164 | 165 | will query using data_warehouse configuraton. 166 | 167 | 168 | Alternatively you can use the method provided by activecube. It will 169 | make the connection for the model or abstract class, which is super class for your models: 170 | 171 | ```ruby 172 | My::TransfersCube.connected_to(database: :data_warehouse) do |cube| 173 | cube.slice( 174 | date: cube.dimensions[:date][:date].format('%Y-%m'), 175 | currency: cube.dimensions[:currency][:symbol] 176 | ).measure(:count).query 177 | end 178 | ``` 179 | 180 | ## How it works 181 | 182 | When you construct and execute cube query with any outcome ( sql, Arel query or ResultSet), 183 | the same sequence of operations happen: 184 | 185 | 1) Cube is collecting the query into a set of objects from the chain method call; 186 | 2) Query is matched against the physical tables, the tables are selected that can serve the query or its part. For example, one table can provide one set of metrics, and the other can provide remaining; 187 | 3) If possible, the variant is selected from all possible options, which uses indexes with the most cardinality 188 | 4) Query is constructed using Arel SQL engine ( included in ActiveRecord ) using selected tables, and possibly joins 189 | 5) If requested, the query is converted to sql ( using Arel visitor ) or executed with database connection 190 | 191 | ## Optimization 192 | 193 | The optimization on step #3 try to minimize the total cost of execution: 194 | 195 | ![Formula min max](https://latex.codecogs.com/png.latex?min(\sum_{tables}max_{metrics}(cost)))) 196 | 197 | where 198 | 199 | ![Formula cost](https://latex.codecogs.com/png.latex?\inline&space;cost(metric,table)&space;=&space;1&space;/&space;(1&space;+&space;cardinality(metric,&space;table))) 200 | 201 | Optimization is done using the algorithm, which checks possible combinations of metrics and tables. 202 | 203 | 204 | ## Development 205 | 206 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 207 | 208 | 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`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 209 | 210 | ## RSPec tests 211 | 212 | 213 | To run tests, you need clickhouse server installation. 214 | Tests use database 'test' that have to be created in clickhouse as: 215 | ```sql 216 | CREATE DATABASE test; 217 | ``` 218 | Check credentials for connection in [spec/spec_helper.rb](spec/spec_helper.rb) file. 219 | By default clickhouse must reside on "clickhouse" server name, port 8123 with the default user access open. 220 | 221 | ## Contributing 222 | 223 | Bug reports and pull requests are welcome on GitHub at https://github.com/bitquery/activecube. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 224 | 225 | ## License 226 | 227 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 228 | 229 | ## Code of Conduct 230 | 231 | Everyone interacting in the Activecube project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/bitquery/activecube/blob/master/CODE_OF_CONDUCT.md). 232 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /activecube.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'activecube/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'activecube' 7 | spec.version = Activecube::VERSION 8 | spec.authors = ['Aleksey Studnev'] 9 | spec.email = ['astudnev@gmail.com'] 10 | 11 | spec.summary = 'Multi-Dimensional Queries with Rails' 12 | spec.description = 'Activecube is the library to make multi-dimensional queries to data.Cube, dimensions, metrics and selectors are defined in the Model, similary to ActiveRecord. 13 | Activecube uses Rails ActiveRecord in implementation. In particular, you have to define all tables, used in Activecube, as ActiveRecord tables.' 14 | spec.homepage = 'https://github.com/bitquery/activecube' 15 | spec.license = 'MIT' 16 | 17 | # Specify which files should be added to the gem when it is released. 18 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 19 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 20 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 21 | end 22 | spec.bindir = 'exe' 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.add_runtime_dependency 'activerecord', '>= 5.2' 27 | 28 | spec.add_development_dependency 'bundler', '~> 1.17' 29 | spec.add_development_dependency 'rake', '>= 12.3.3' 30 | spec.add_development_dependency 'rspec', '~> 3.0' 31 | spec.metadata['rubygems_mfa_required'] = 'true' 32 | end 33 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "activecube" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/activecube.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/version' 2 | require 'activecube/active_record_extension' 3 | 4 | require 'activecube/input_argument_error' 5 | require 'activecube/base' 6 | require 'activecube/view' 7 | require 'activecube/dimension' 8 | require 'activecube/metric' 9 | require 'activecube/selector' 10 | 11 | require 'activecube/common/metrics' 12 | 13 | require 'active_record' 14 | 15 | module Activecube 16 | # include the extension 17 | ActiveRecord::Base.include Activecube::ActiveRecordExtension 18 | end 19 | -------------------------------------------------------------------------------- /lib/activecube/active_record_extension.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Activecube::ActiveRecordExtension 4 | extend ActiveSupport::Concern 5 | 6 | class_methods do 7 | attr_reader :activecube_indexes 8 | 9 | private 10 | 11 | def index(index_name, *args) 12 | (@activecube_indexes ||= []) << Activecube::Processor::Index.new(index_name, *args) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/activecube/base.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/cube_definition' 2 | require 'activecube/query_methods' 3 | 4 | module Activecube 5 | class Base 6 | extend CubeDefinition 7 | extend QueryMethods 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/activecube/common/metrics.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Common 2 | module Metrics 3 | METHODS = %i[count minimum maximum average sum uniqueExact unique median medianExact any anyLast stddevPop varPop entropy skewPop kurtPop] 4 | 5 | METHODS.each do |fname| 6 | if fname == :count 7 | define_method fname do |model, arel_table, measure, cube_query| 8 | if measure.selectors.empty? 9 | Arel.star.count 10 | else 11 | Arel.star.countIf(measure.condition_query(model, arel_table, 12 | cube_query)) 13 | end 14 | end 15 | else 16 | define_method fname do |model, arel_table, measure, cube_query| 17 | column = pre_aggregate_value model, arel_table, measure, cube_query 18 | if measure.selectors.empty? 19 | column.send(fname) 20 | else 21 | column.send(fname.to_s + 'If', 22 | measure.condition_query(model, arel_table, 23 | cube_query)) 24 | end 25 | end 26 | end 27 | end 28 | 29 | def pre_aggregate_value(_model, arel_table, _measure, _cube_query) 30 | arel_table[self.class.column_name.to_sym] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/activecube/cube_definition.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module CubeDefinition 3 | class DefinitionError < ::StandardError 4 | end 5 | 6 | class NamedHash < Hash 7 | def initialize(cube, entry_class) 8 | @cube = cube 9 | @entry_class = entry_class 10 | end 11 | 12 | def [](key) 13 | v = super key 14 | v.nil? ? nil : @entry_class.new(@cube, key, v.new) 15 | end 16 | end 17 | 18 | attr_reader :dimensions, :metrics, :selectors, :models, :options 19 | 20 | def inspect 21 | name + 22 | (@dimensions && " Dimensions: #{@dimensions.keys.join(',')}") + 23 | (@metrics && " Metrics: #{@metrics.keys.join(',')}") + 24 | (@selectors && " Selectors: #{@selectors.keys.join(',')}") + 25 | (@models && " Models: #{@models.map(&:name).join(',')}") 26 | end 27 | 28 | private 29 | 30 | def dimension(data) 31 | store_definition_map! 'dimension', (@dimensions ||= NamedHash.new(self, Query::Slice)), data 32 | end 33 | 34 | def metric(data) 35 | store_definition_map! 'metric', (@metrics ||= NamedHash.new(self, Query::Measure)), data 36 | end 37 | 38 | def selector(data) 39 | store_definition_map! 'filter', (@selectors ||= NamedHash.new(self, Query::Selector)), data 40 | end 41 | 42 | def table(*args) 43 | store_definition_array! 'model', (@models ||= []), [*args].flatten.map { |t| t } 44 | end 45 | 46 | def option(*args) 47 | store_definition_array! 'option', (@options ||= []), [*args].flatten.map { |t| t } 48 | end 49 | 50 | def dim_column(column_name) 51 | Class.new(Activecube::Dimension) do 52 | column column_name 53 | end 54 | end 55 | 56 | def metric_column(column_name) 57 | Class.new(Activecube::Metric) do 58 | include Activecube::Common::Metrics 59 | 60 | column column_name 61 | 62 | modifier :calculate 63 | 64 | define_method :expression do |model, arel_table, measure, cube_query| 65 | if calculate = measure.modifier(:calculate) 66 | send(calculate.args.first, model, arel_table, measure, cube_query) 67 | else 68 | sum(model, arel_table, measure, cube_query) 69 | end 70 | end 71 | end 72 | end 73 | 74 | def select_column(column_name) 75 | Class.new(Activecube::Selector) do 76 | column column_name 77 | end 78 | end 79 | 80 | def store_definition_map!(name, map, data) 81 | data.each_pair do |key, class_def| 82 | raise DefinitionError, "#{key} already defined for #{name}" if map.has_key?(key) 83 | 84 | map[key] = class_def 85 | end 86 | end 87 | 88 | def store_definition_array!(name, array, data) 89 | values = data & array 90 | raise DefinitionError, "#{values.join(',')} already defined for #{name}" unless values.empty? 91 | 92 | array.concat data 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/activecube/definition_methods.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/cube_definition' 2 | require 'activecube/field' 3 | require 'activecube/modifier' 4 | 5 | module Activecube 6 | module DefinitionMethods 7 | attr_reader :column_names 8 | 9 | def column_name 10 | raise "Not defined column for a dimension or metric #{name}" if !column_names || column_names.empty? 11 | raise "Defined more than one column for a dimension or metric #{name}" if column_names.count > 1 12 | 13 | column_names.first 14 | end 15 | 16 | private 17 | 18 | def column(*args) 19 | array = (@column_names ||= []) 20 | data = [*args].flatten 21 | values = data & array 22 | raise DefinitionError, "#{values.join(',')} already defined for columns in #{name}" unless values.empty? 23 | 24 | array.concat data 25 | end 26 | end 27 | 28 | module DimensionDefinitionMethods 29 | include DefinitionMethods 30 | 31 | attr_reader :identity, :identity_expression, :fields 32 | attr_accessor :include_group_by 33 | 34 | private 35 | 36 | def identity_column(*args) 37 | raise "Identity already defined as #{identity} for #{name}" if @identity 38 | 39 | @identity = args.first 40 | @identity_expression = args.second 41 | end 42 | 43 | def always_include_group_by(val_bool) 44 | @include_group_by = val_bool 45 | end 46 | 47 | def field(*args) 48 | name = args.first.to_sym 49 | (@fields ||= {})[name] = args.second 50 | end 51 | end 52 | 53 | module MetricDefinitionMethods 54 | include DefinitionMethods 55 | 56 | attr_reader :modifiers, :tuple 57 | 58 | private 59 | 60 | def modifier(*args) 61 | (@modifiers ||= {})[args.first.to_sym] = Modifier.new(*args) 62 | end 63 | 64 | def tuple_fields(*args) 65 | @tuple = args 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/activecube/dimension.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/definition_methods' 2 | 3 | module Activecube 4 | class Dimension 5 | extend DimensionDefinitionMethods 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activecube/field.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | class Field 3 | attr_accessor :name, :definition 4 | 5 | def self.build(name, arg) 6 | if arg.is_a? String 7 | Field.new name, arg 8 | elsif arg.is_a? Hash 9 | Field.new name, arg.symbolize_keys 10 | elsif arg.is_a?(Class) && arg < Field 11 | arg.new name 12 | else 13 | raise Activecube::InputArgumentError, "Unexpected field #{name} definition with #{arg.class.name}" 14 | end 15 | end 16 | 17 | def initialize(name, arg = nil) 18 | @name = name 19 | @definition = arg 20 | end 21 | 22 | def expression(_model, _arel_table, _slice, _cube_query) 23 | unless definition.is_a?(String) 24 | raise Activecube::InputArgumentError, 25 | "String expression expected for #{name} field, instead #{definition.class.name} is found" 26 | end 27 | 28 | definition 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/activecube/input_argument_error.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | class InputArgumentError < ::ArgumentError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/activecube/metric.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/definition_methods' 2 | 3 | module Activecube 4 | class Metric 5 | extend MetricDefinitionMethods 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activecube/modifier.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | class Modifier 3 | attr_reader :name, :definition 4 | 5 | def initialize(*args) 6 | @name = args.first 7 | @definition = args.second 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/activecube/processor/composer.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/processor/index' 2 | require 'activecube/processor/measure_tables' 3 | require 'activecube/processor/optimizer' 4 | require 'activecube/processor/table' 5 | require 'activecube/query/measure_nothing' 6 | 7 | module Activecube::Processor 8 | class Composer 9 | attr_reader :cube_query, :models, :query 10 | 11 | def initialize(cube_query) 12 | @cube_query = cube_query 13 | end 14 | 15 | def build_query 16 | @query = compose_queries optimize! ranked_tables 17 | end 18 | 19 | def connection 20 | connections = models.map(&:connection).compact.uniq 21 | # for views 22 | if connections.empty? && !models.empty? 23 | connections = models.first&.models&.map(&:connection)&.compact&.uniq || [] 24 | end 25 | raise 'No connection found for query' if connections.empty? 26 | if connections.count > 1 27 | raise "Tables #{models.map(&:name).join(',')} mapped to multiple connections, can not query" 28 | end 29 | 30 | connections.first 31 | end 32 | 33 | private 34 | 35 | def optimize!(measure_tables) 36 | all_tables = measure_tables.map(&:tables).map(&:keys).flatten.uniq 37 | 38 | cost_matrix = measure_tables.collect do |measure_table| 39 | all_tables.collect do |table| 40 | measure_table.tables[table].try(&:cost) 41 | end 42 | end 43 | 44 | before = total_cost measure_tables 45 | Optimizer.new(cost_matrix).optimize.each_with_index do |optimal, index| 46 | measure_tables[index].selected = measure_tables[index].entries.map(&:table).index(all_tables[optimal]) 47 | end 48 | after = total_cost measure_tables 49 | 50 | raise "Optimizer made it worse #{before} -> #{after} for #{cost_matrix}" unless after <= before 51 | 52 | measure_tables 53 | end 54 | 55 | def total_cost(measure_tables) 56 | measure_tables.group_by(&:table).collect { |t| t.second.map(&:entry).map(&:cost).max }.sum 57 | end 58 | 59 | def ranked_tables 60 | tables = cube_query.tables.select { |table| table.matches? cube_query, [] } 61 | measures = if cube_query.measures.empty? 62 | [Activecube::Query::MeasureNothing.new(cube_query.cube)] 63 | else 64 | cube_query.measures 65 | end 66 | measures.collect do |measure| 67 | by = MeasureTables.new measure 68 | tables.each do |table| 69 | next unless table.measures? measure 70 | 71 | max_cardinality_index = table.model.activecube_indexes.select do |index| 72 | index.indexes? cube_query, [measure] 73 | end.sort_by(&:cardinality).last 74 | by.add_table table, max_cardinality_index 75 | end 76 | if by.tables.empty? 77 | raise "Metric #{measure.key} #{measure.definition.try(:name) || measure.class.name} can not be measured by any of tables #{tables.map(&:name).join(',')}" 78 | end 79 | 80 | by 81 | end 82 | end 83 | 84 | def compose_queries(measure_tables) 85 | composed_query = nil 86 | @models = [] 87 | measures_by_tables = measure_tables.group_by(&:table) 88 | measures_by_tables.each_pair do |table, list| 89 | @models << table.model 90 | table_query = table.query cube_query, list.map(&:measure) 91 | composed_query = composed_query ? table.join(cube_query, composed_query, table_query) : table_query 92 | end 93 | composed_query 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/activecube/processor/index.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Processor 3 | class Index 4 | attr_reader :fields, :cardinality, :required, :indexes 5 | 6 | def initialize(name, *args) 7 | @fields = [name].flatten 8 | @cardinality = args.first && args.first[:cardinality] 9 | @required = args.first && args.first[:required] 10 | # if true this index will definitely be used 11 | @indexes = args.first && args.first[:indexes] 12 | end 13 | 14 | def indexes?(query, measures) 15 | indexes || (fields - query.selector_column_names(measures)).empty? 16 | end 17 | 18 | def matches?(query, measures) 19 | !required || (fields - query.column_names_required(measures)).empty? 20 | end 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /lib/activecube/processor/measure_tables.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Processor 3 | class MeasureTables 4 | class Entry 5 | attr_reader :table, :index, :cardinality, :cost 6 | 7 | def initialize(table, index) 8 | @table = table 9 | @index = index 10 | @cardinality = index ? index.cardinality : 0 11 | @cost = 1.0 / (1.0 + cardinality) 12 | end 13 | end 14 | 15 | attr_reader :measure, :entries, :tables 16 | attr_accessor :selected 17 | 18 | def initialize(measure) 19 | @measure = measure 20 | @tables = {} 21 | @entries = [] 22 | @selected = 0 23 | end 24 | 25 | def add_table(table, index) 26 | e = Entry.new(table, index) 27 | entries << e 28 | tables[table] = e 29 | end 30 | 31 | def table 32 | entry.table 33 | end 34 | 35 | def entry 36 | entries[selected] 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/activecube/processor/optimizer.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Processor 2 | class Optimizer 3 | UNLIM_COST = 9999 4 | MAX_ITERATIONS = 3 5 | 6 | attr_reader :tables_count, :metrics_count, :cost_matrix 7 | 8 | def initialize(cost_matrix) 9 | @cost_matrix = cost_matrix 10 | @cache = ActiveSupport::Cache::MemoryStore.new 11 | end 12 | 13 | def optimize 14 | @cache.fetch(cost_matrix, expires_in: 12.hours) do 15 | @tables_count = cost_matrix.map(&:count).max 16 | @metrics_count = cost_matrix.count 17 | 18 | tables_count == 1 || metrics_count == 0 ? [0] * metrics_count : do_optimize 19 | end 20 | end 21 | 22 | private 23 | 24 | def generate_variants(vs, metric_i) 25 | return vs if metric_i == metrics_count 26 | 27 | metric_tables = cost_matrix[metric_i].map.with_index do |c, index| 28 | [index] if c 29 | end.compact 30 | 31 | vsnew = if metric_i == 0 32 | metric_tables 33 | else 34 | arry = [] 35 | vs.each do |v| 36 | metric_tables.each do |newv| 37 | arry << (v + newv) 38 | end 39 | end 40 | arry 41 | end 42 | 43 | generate_variants vsnew, metric_i + 1 44 | end 45 | 46 | def cost_for(variant) 47 | variant.each_with_index.group_by(&:first).collect do |table_index, arry| 48 | arry.map(&:second).map { |metric_index| cost_matrix[metric_index][table_index] }.max 49 | end.sum 50 | end 51 | 52 | def do_optimize 53 | # variants = generate_variants [], 0 54 | variants = gen_reduced_variants 55 | variant_costs = variants.map { |v| cost_for v } 56 | variants[variant_costs.each_with_index.min.second] 57 | end 58 | 59 | def gen_permutations(n, k) 60 | perm = Array.new(k, 0) 61 | perms = [] 62 | 63 | while true 64 | perms.push perm.dup 65 | flag = true 66 | i = 0 67 | ((k - 1)...-1).step(-1).each do |ii| 68 | i = ii 69 | if perm[ii] < n - 1 70 | flag = false 71 | break 72 | end 73 | end 74 | return perms if flag 75 | 76 | perm[i] += 1 77 | ((i + 1)...k).each { |j| perm[j] = 0 } 78 | end 79 | end 80 | 81 | def gen_reduced_variants 82 | # reduce size of cost_matrix deleting duplicates 83 | uniq_rows = [] 84 | rows_indices = {} 85 | possible_tables = {} 86 | cost_matrix.each_with_index do |row, i| 87 | flag = false 88 | 89 | uniq_rows.each_with_index do |u_row, j| 90 | if u_row.eql? row 91 | flag = true 92 | rows_indices[i] = j 93 | end 94 | end 95 | 96 | next if flag 97 | 98 | rows_indices[i] = uniq_rows.length 99 | possible_tables[uniq_rows.length] = row.map.with_index { |c, index| [index, true] if c }.compact.to_h 100 | uniq_rows.push(row) 101 | end 102 | 103 | # generating variants for reduced matrix 104 | vars = gen_permutations(tables_count, uniq_rows.length) 105 | 106 | # filter possible variants 107 | vars = vars.filter do |v| 108 | v.map.with_index.all? { |t_n, i| possible_tables[i][t_n] } 109 | end 110 | 111 | # restore variants for full matrix 112 | vars.map do |variant| 113 | full_v = Array.new(metrics_count) 114 | rows_indices.each { |k, v| full_v[k] = variant[v] } 115 | 116 | full_v 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/activecube/processor/table.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Processor 2 | class Table 3 | attr_reader :model 4 | 5 | def initialize(model) 6 | @model = model 7 | end 8 | 9 | def name 10 | model.table_name 11 | end 12 | 13 | def matches?(query, measures = query.measures) 14 | (query.column_names(measures) - model.attribute_types.keys).empty? && 15 | !model.activecube_indexes.detect { |index| !index.matches?(query, measures) } 16 | end 17 | 18 | def measures?(measure) 19 | (measure.required_column_names - model.attribute_types.keys).empty? 20 | end 21 | 22 | def use_group_by?(cube_query) 23 | !cube_query.options.detect{|op| op.try(:argument) && op.argument == :group_by && op.value == false} 24 | end 25 | 26 | def query(cube_query, measures = cube_query.measures) 27 | table = model.arel_table 28 | query = table 29 | 30 | # Handle slices 31 | cube_query.slices.each do |s| 32 | with_group_by = use_group_by?(cube_query) && (dimension_include_group_by?(s) || any_metrics_specified?(measures)) 33 | 34 | s.include_group_by = with_group_by 35 | query = s.append_query(model, cube_query, table, query) 36 | end 37 | 38 | # Handle measures, selectors, and options 39 | (measures + cube_query.selectors + cube_query.options).each do |s| 40 | query = s.append_query(model, cube_query, table, query) 41 | end 42 | 43 | query 44 | end 45 | 46 | def join(cube_query, left_query, right_query) 47 | outer_table = model.arel_table.class.new('').project(Arel.star) 48 | 49 | dimension_names = (cube_query.join_fields + cube_query.slices.map { |s| s.key }).uniq 50 | 51 | left_query_copy = left_query.deep_dup.remove_options 52 | right_query_copy = right_query.deep_dup.remove_options 53 | 54 | query = outer_table.from(left_query_copy) 55 | 56 | query = if dimension_names.empty? 57 | query.cross_join(right_query_copy) 58 | else 59 | query.join(right_query_copy, ::Arel::Nodes::FullOuterJoin) 60 | .using(*dimension_names) 61 | end 62 | 63 | cube_query.options.each do |option| 64 | query = option.append_query(model, cube_query, outer_table, query) 65 | end 66 | 67 | query 68 | end 69 | 70 | private 71 | 72 | def dimension_include_group_by?(slice) 73 | slice.dimension_include_group_by 74 | end 75 | 76 | def any_metrics_specified?(measures) 77 | # that means if there are no measures in the query, we don't need to group by. 78 | return false if measures.first.is_a?(Activecube::Query::MeasureNothing) 79 | 80 | true 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/activecube/processor/template.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Processor 3 | # Example of template: 4 | # toFloat64({{gas_price}})/1.0e9 5 | # 6 | # You can also put multiple templates in one string: 7 | # toFloat64({{gas_used}})*toFloat64({{gas_price}})/1.0e18 8 | class Template 9 | attr_reader :text 10 | 11 | TEMPLATE_REGEXP = /{{([^}]+)}}/.freeze 12 | TEMPLATE_METHODS_LIST = { 13 | empty: '{{template}}', 14 | any: 'any({{template}})' 15 | }.freeze 16 | 17 | def initialize(text) 18 | @text = text 19 | end 20 | 21 | def template_specified? 22 | return false unless text 23 | 24 | text.match?(TEMPLATE_REGEXP) 25 | end 26 | 27 | def apply_template(template_method) 28 | template_pattern = TEMPLATE_METHODS_LIST[template_method.to_sym] 29 | 30 | replaced_templates = extract_text_templates.map do |dt| 31 | template_pattern.gsub('{{template}}', dt) 32 | end 33 | 34 | replace_text_templates(replaced_templates) 35 | end 36 | 37 | private 38 | 39 | def extract_text_templates 40 | text.scan(TEMPLATE_REGEXP).flatten 41 | end 42 | 43 | def replace_text_templates(replaced_templates) 44 | text.gsub(/{{[^}]+}}/).with_index do |_, i| 45 | replaced_templates[i] 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/activecube/query/chain_appender.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Query 2 | module ChainAppender 3 | private 4 | 5 | def append(*args, list, def_class, definitions) 6 | list.concat args.map { |arg| 7 | if arg.is_a?(Symbol) && definitions 8 | definitions[arg] 9 | elsif arg.is_a?(def_class) 10 | arg 11 | elsif arg.is_a? Hash 12 | arg.collect do |pair| 13 | unless pair.second.is_a?(def_class) 14 | raise Activecube::InputArgumentError, 15 | "Unexpected #{pair.second.class.name} to use for #{def_class} as #{arg}[#{pair.first}]" 16 | end 17 | 18 | pair.second.alias! pair.first 19 | end 20 | else 21 | raise Activecube::InputArgumentError, "Unexpected #{arg.class} to use for #{def_class} as #{arg}" 22 | end 23 | }.flatten 24 | self 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/activecube/query/cube_query.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/query/chain_appender' 2 | require 'activecube/query/item' 3 | require 'activecube/query/limit' 4 | require 'activecube/query/limit_by' 5 | require 'activecube/query/measure' 6 | require 'activecube/query/ordering' 7 | require 'activecube/query/option' 8 | require 'activecube/query/selector' 9 | require 'activecube/query/slice' 10 | 11 | require 'activecube/processor/composer' 12 | 13 | module Activecube::Query 14 | class CubeQuery 15 | include ChainAppender 16 | 17 | attr_reader :cube, :slices, :measures, :selectors, :options, :tables, :sql 18 | attr_accessor :stats, :headers 19 | 20 | def initialize(cube, slices = [], measures = [], selectors = [], options = [], model_tables = nil) 21 | @cube = cube 22 | @slices = slices 23 | @measures = measures 24 | @selectors = selectors 25 | @options = options 26 | 27 | @tables = model_tables || cube.models.map do |m| 28 | m < Activecube::View ? m.new : Activecube::Processor::Table.new(m) 29 | end 30 | 31 | cube.options && cube.options.each do |option| 32 | define_singleton_method option.to_s.underscore do |*args| 33 | @options << Option.new(option, *args) 34 | self 35 | end 36 | end 37 | end 38 | 39 | def group_by(*args) 40 | clear_sql 41 | @options << Option.new(:group_by, *args) 42 | self 43 | end 44 | 45 | def slice(*args) 46 | clear_sql 47 | append(*args, @slices, Slice, cube.dimensions) 48 | end 49 | 50 | def measure(*args) 51 | clear_sql 52 | append(*args, @measures, Measure, cube.metrics) 53 | end 54 | 55 | def when(*args) 56 | clear_sql 57 | append(*args, @selectors, Selector, cube.selectors) 58 | end 59 | 60 | def desc(*args) 61 | clear_sql 62 | args.each do |arg| 63 | options << Ordering.new(arg, :desc) 64 | end 65 | self 66 | end 67 | 68 | def desc_by_integer(*args) 69 | clear_sql 70 | args.each do |arg| 71 | options << Ordering.new(arg, :desc, options = { with_length: true }) 72 | end 73 | self 74 | end 75 | 76 | def asc(*args) 77 | clear_sql 78 | args.each do |arg| 79 | options << Ordering.new(arg, :asc) 80 | end 81 | self 82 | end 83 | 84 | def asc_by_integer(*args) 85 | clear_sql 86 | args.each do |arg| 87 | options << Ordering.new(arg, :asc, options = { with_length: true }) 88 | end 89 | self 90 | end 91 | 92 | def offset(*args) 93 | clear_sql 94 | args.each do |arg| 95 | options << Limit.new(arg, :skip) 96 | end 97 | self 98 | end 99 | 100 | def limit(*args) 101 | clear_sql 102 | args.each do |arg| 103 | options << Limit.new(arg, :take) 104 | end 105 | self 106 | end 107 | 108 | def limit_by(*args) 109 | clear_sql 110 | options << LimitBy.new(args) 111 | self 112 | end 113 | 114 | def query 115 | composed_query = to_query 116 | connection = @composed.connection 117 | if connection.respond_to?(:with_statistics) 118 | connection.with_statistics(stats, headers) do 119 | connection.exec_query(composed_query.to_sql) 120 | end 121 | else 122 | connection.exec_query(composed_query.to_sql) 123 | end 124 | end 125 | 126 | def to_query 127 | @composed.try(:query) || (@composed = Activecube::Processor::Composer.new(self)).build_query 128 | end 129 | 130 | def to_sql 131 | to_query.to_sql 132 | end 133 | 134 | def column_names(measures = self.measures) 135 | (measures + slices + selectors).map(&:required_column_names).flatten.uniq 136 | end 137 | 138 | def column_names_required(measures = self.measures) 139 | (measures + slices + selectors.select do |s| 140 | !s.is_a?(Selector::CombineSelector) 141 | end).map(&:required_column_names).flatten.uniq 142 | end 143 | 144 | def selector_column_names(measures = self.measures) 145 | (measures.map(&:selectors) + slices.map(&:selectors) + selectors).flatten.select(&:is_indexed?).map(&:required_column_names).flatten.uniq 146 | end 147 | 148 | def join_fields 149 | slices.map(&:group_by_columns).flatten.uniq 150 | end 151 | 152 | def orderings 153 | options.select { |s| s.is_a? Ordering } 154 | end 155 | 156 | private 157 | 158 | def clear_sql 159 | @composed = nil 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/activecube/query/item.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Query 2 | class Item 3 | include ChainAppender 4 | 5 | attr_reader :cube, :key, :definition 6 | 7 | def initialize(cube, key, definition) 8 | @key = key 9 | @cube = cube 10 | @definition = definition 11 | end 12 | 13 | def required_column_names 14 | definition.class.column_names || [] 15 | end 16 | 17 | def alias!(new_key) 18 | self.class.new cube, new_key, definition 19 | end 20 | 21 | def to_s 22 | "#{definition.class.name}(#{key})" 23 | end 24 | 25 | def append_with!(model, cube_query, table, query) 26 | if definition.respond_to?(:with_expression) && 27 | (with_expression = definition.with_expression(model, cube_query, table, query)) 28 | with_expression.each_pair do |key, expr| 29 | query = try_append_with(query, key, expr) 30 | end 31 | end 32 | query 33 | end 34 | 35 | private 36 | 37 | def try_append_with(query, key, expr) 38 | expr = Arel.sql(expr) if expr.is_a?(String) 39 | query = query.where(Arel.sql('1')) unless query.respond_to?(:ast) 40 | if (with = query.ast.with) 41 | existing = with.expr.detect { |expr| expr.right == key } 42 | if existing 43 | if existing.left != expr.to_s 44 | raise "Key #{key} defined twice in WITH statement, with different expressions #{expr.to_sql} AND #{existing.left}" 45 | end 46 | 47 | query 48 | else 49 | query.with(with.expr + [expr.as(key)]) 50 | end 51 | else 52 | query.with(expr.as(key)) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/activecube/query/limit.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Query 3 | class Limit 4 | attr_reader :argument, :option 5 | 6 | def initialize(argument, option) 7 | @argument = argument 8 | @option = option 9 | end 10 | 11 | def append_query(_model, _cube_query, _table, query) 12 | query.send(option, argument) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/activecube/query/limit_by.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Query 3 | class LimitBy 4 | attr_reader :each, :limit, :offset 5 | 6 | def initialize(arguments) 7 | map = arguments.to_h 8 | @each = map[:each] 9 | @limit = map[:limit] 10 | @offset = map[:offset] || 0 11 | end 12 | 13 | def append_query(_model, cube_query, _table, query) 14 | allowed_limit_by = (cube_query.measures + cube_query.slices).to_h { |k| [k.key, true] } 15 | 16 | limit_by = [] 17 | # allow limit by like by: "key1,key2" for backward compatibility 18 | each.delete(' ').split(',').each do |s| 19 | prefixed_s = Activecube::Graphql::ParseTree::Element::KEY_FIELD_PREFIX + s 20 | 21 | if allowed_limit_by[s] 22 | limit_by << quote(s) 23 | elsif allowed_limit_by[prefixed_s] 24 | limit_by << quote(prefixed_s) 25 | else 26 | key_wo_prefix = s.delete_prefix(Activecube::Graphql::ParseTree::Element::KEY_FIELD_PREFIX) 27 | raise GraphqlError::ArgumentError, "Can't use #{key_wo_prefix} in limit by. Missing field #{key_wo_prefix} in executed query" 28 | end 29 | end 30 | 31 | new_each = limit_by.join(',') 32 | query.limit_by new_each, limit, offset 33 | end 34 | 35 | def quote(s) 36 | if /^[\w.]+$/.match?(s) 37 | "`#{s}`" 38 | else 39 | s 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/activecube/query/measure.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/modifier' 2 | require 'activecube/query/modification' 3 | 4 | module Activecube::Query 5 | class Measure < Item 6 | attr_reader :selectors, :modifications 7 | 8 | def initialize(cube, key, definition, selectors = [], modifications = []) 9 | super cube, key, definition 10 | @selectors = selectors 11 | @modifications = modifications 12 | 13 | modifier_methods! if definition && definition.class.modifiers 14 | end 15 | 16 | def required_column_names 17 | ((definition.class.column_names || []) + selectors.map(&:required_column_names)).flatten.uniq 18 | end 19 | 20 | def when(*args) 21 | append(*args, @selectors, Selector, cube.selectors) 22 | end 23 | 24 | def alias!(new_key) 25 | self.class.new cube, new_key, definition, selectors, modifications 26 | end 27 | 28 | def condition_query(model, arel_table, cube_query) 29 | condition = nil 30 | selectors.each do |selector| 31 | condition = if condition 32 | condition.and(selector.expression(model, arel_table, cube_query)) 33 | else 34 | selector.expression(model, arel_table, cube_query) 35 | end 36 | end 37 | condition 38 | end 39 | 40 | def append_query(model, cube_query, table, query) 41 | query = append_with!(model, cube_query, table, query) 42 | attr_alias = "`#{key}`" 43 | expr = definition.expression model, table, self, cube_query 44 | query.project expr.as(attr_alias) 45 | end 46 | 47 | def to_s 48 | "Metric #{super}" 49 | end 50 | 51 | def modifier(name) 52 | ms = modifications.select { |m| m.modifier.name == name } 53 | raise "Found multiple (#{ms.count}) definitions for #{name} in #{self}" if ms.count > 1 54 | 55 | ms.first 56 | end 57 | 58 | private 59 | 60 | def modifier_methods! 61 | definition.class.modifiers.each_pair do |key, modifier| 62 | define_singleton_method key do |*args| 63 | (@modifications ||= []) << Modification.new(modifier, *args) 64 | self 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/activecube/query/measure_nothing.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/query/measure' 2 | 3 | module Activecube::Query 4 | class MeasureNothing < Measure 5 | def initialize(cube) 6 | super cube, nil, nil 7 | end 8 | 9 | def required_column_names 10 | [] 11 | end 12 | 13 | def append_query(_model, _cube_query, _table, query) 14 | query 15 | end 16 | 17 | def to_s 18 | 'Measure nothing, used for queries where no metrics defined' 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/activecube/query/modification.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/modifier' 2 | module Activecube 3 | module Query 4 | class Modification 5 | attr_reader :modifier, :args 6 | 7 | def initialize(modifier, *args) 8 | @modifier = modifier 9 | @args = args 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/activecube/query/option.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Query 3 | class Option 4 | attr_reader :argument, :value 5 | 6 | def initialize(argument, value) 7 | @argument = argument 8 | @value = value 9 | end 10 | 11 | def append_query(_model, _cube_query, _table, query) 12 | query 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/activecube/query/ordering.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | module Query 3 | class Ordering 4 | attr_reader :argument, :direction, :options 5 | 6 | def initialize(argument, direction, options = {}) 7 | @argument = argument 8 | @direction = direction 9 | @options = options 10 | end 11 | 12 | def append_query(_model, cube_query, _table, query) 13 | allowed_sort_keys = (cube_query.measures + cube_query.slices).to_h { |k| [k.key, true] } 14 | 15 | sort_keys = [] 16 | # allow ordering like desc: "key1,key2" for backward compatibility 17 | argument.to_s.delete(' ').split(',').each do |s| 18 | prefixed_s = Activecube::Graphql::ParseTree::Element::KEY_FIELD_PREFIX + s 19 | 20 | if allowed_sort_keys[s] 21 | sort_keys << quote(s) 22 | elsif allowed_sort_keys[prefixed_s] 23 | sort_keys << quote(prefixed_s) 24 | else 25 | key_wo_prefix = s.delete_prefix(Activecube::Graphql::ParseTree::Element::KEY_FIELD_PREFIX) 26 | raise GraphqlError::ArgumentError, "Can't use #{key_wo_prefix} in sorting. Missing field #{key_wo_prefix} in executed query" 27 | end 28 | end 29 | 30 | @text = sort_keys.join(',') 31 | 32 | return by_length_order(query) if options[:with_length] 33 | 34 | simple_order(query) 35 | end 36 | 37 | def quote(s) 38 | if /^[\w.]+$/.match?(s) 39 | "`#{s}`" 40 | else 41 | s 42 | end 43 | end 44 | 45 | private 46 | 47 | attr_reader :text 48 | 49 | def simple_order(query) 50 | query.order(::Arel.sql(text).send(direction)) 51 | end 52 | 53 | def by_length_order(query) 54 | query.order( 55 | ::Arel.sql("LENGTH(#{text})").send(direction), 56 | ::Arel.sql(text).send(direction) 57 | ) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/activecube/query/selector.rb: -------------------------------------------------------------------------------- 1 | module Activecube::Query 2 | class Selector < Item 3 | OPERATORS = %w[eq not_eq gt lt gteq lteq in not_in between] 4 | ARRAY_OPERATORS = %w[in not_in] 5 | ARRAY_OPERATOR_MAP = { 6 | 'eq' => 'in', 7 | 'not_eq' => 'not_in' 8 | } 9 | INDEX_OPERATORS = %w[eq in] 10 | 11 | class CombineSelector < Selector 12 | attr_reader :selectors 13 | def initialize(selectors, operator) 14 | @selectors = selectors 15 | @operator = operator 16 | end 17 | 18 | def required_column_names 19 | @selectors.map(&:required_column_names).uniq 20 | end 21 | 22 | def to_s 23 | "Selector #{operator.operation}(#{@selectors.map(&:to_s).join(',')})" 24 | end 25 | 26 | def expression(model, arel_table, cube_query) 27 | expr = nil 28 | @selectors.each do |s| 29 | expr = if expr 30 | expr.send(operator.operation, 31 | s.expression(model, arel_table, 32 | cube_query)) 33 | else 34 | s.expression(model, arel_table, cube_query) 35 | end 36 | end 37 | expr 38 | end 39 | 40 | def append_query(model, cube_query, arel_table, query) 41 | @selectors.each do |s| 42 | query = s.append_with!(model, cube_query, arel_table, query) 43 | end 44 | 45 | query.where expression(model, arel_table, cube_query) 46 | end 47 | end 48 | 49 | class Operator 50 | attr_reader :operation, :argument 51 | 52 | def initialize(operation, argument) 53 | @operation = operation 54 | @argument = argument 55 | end 56 | 57 | def expression(_model, left, right) 58 | if right.is_a?(Array) && (matching_array_op = ARRAY_OPERATOR_MAP[operation]) 59 | left.send(matching_array_op, right) 60 | else 61 | left.send(operation, right) 62 | end 63 | end 64 | 65 | def eql?(other) 66 | other.is_a?(Operator) && 67 | operation == other.operation && 68 | argument == other.argument 69 | end 70 | 71 | def ==(other) 72 | eql? other 73 | end 74 | 75 | def hash 76 | operation.hash + argument.hash 77 | end 78 | end 79 | 80 | attr_reader :operator 81 | 82 | def initialize(cube, key, definition, operator = nil) 83 | super cube, key, definition 84 | @operator = operator 85 | end 86 | 87 | OPERATORS.each do |method| 88 | define_method(method) do |*args| 89 | raise Activecube::InputArgumentError, "Selector for #{method} already set" if operator 90 | 91 | if ARRAY_OPERATORS.include? method 92 | @operator = Operator.new(method, args.flatten) 93 | elsif method == 'between' 94 | if args.is_a?(Range) 95 | @operator = Operator.new(method, args) 96 | elsif args.is_a?(Array) && (arg = args.flatten).count == 2 97 | @operator = Operator.new(method, arg[0]..arg[1]) 98 | else 99 | raise Activecube::InputArgumentError, 100 | "Unexpected size of arguments for #{method}, must be Range or Array of 2" 101 | end 102 | else 103 | raise Activecube::InputArgumentError, "Unexpected size of arguments for #{method}" unless args.size == 1 104 | 105 | @operator = Operator.new(method, args.first) 106 | end 107 | self 108 | end 109 | end 110 | 111 | alias since gteq 112 | alias till lteq 113 | alias is eq 114 | alias not not_eq 115 | alias after gt 116 | alias before lt 117 | 118 | def alias!(new_key) 119 | self.class.new cube, new_key, definition, operator 120 | end 121 | 122 | def append_query(model, cube_query, table, query) 123 | query = append_with!(model, cube_query, table, query) 124 | query.where(expression(model, table, cube_query)) 125 | end 126 | 127 | def expression(model, arel_table, cube_query) 128 | definition.expression model, arel_table, self, cube_query 129 | end 130 | 131 | def eql?(other) 132 | other.is_a?(Selector) && 133 | cube == other.cube && 134 | operator == other.operator && 135 | definition.class == other.definition.class 136 | end 137 | 138 | def ==(other) 139 | eql? other 140 | end 141 | 142 | def hash 143 | definition.class.hash + operator.hash 144 | end 145 | 146 | def to_s 147 | "Selector #{super}" 148 | end 149 | 150 | def is_indexed? 151 | INDEX_OPERATORS.include? operator&.operation 152 | end 153 | 154 | def self.or(selectors) 155 | CombineSelector.new(selectors, Operator.new(:or, nil)) 156 | end 157 | 158 | def self.and(selectors) 159 | CombineSelector.new(selectors, Operator.new(:and, nil)) 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/activecube/query/slice.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/processor/template' 2 | 3 | module Activecube::Query 4 | class Slice < Item 5 | attr_reader :dimension, :dimension_include_group_by, :parent, :selectors 6 | attr_accessor :include_group_by 7 | 8 | def initialize(cube, key, definition, parent = nil, selectors = []) 9 | super cube, key, definition 10 | @dimension = parent ? parent.dimension : definition 11 | @dimension_include_group_by = dimension.class.include_group_by 12 | @parent = parent 13 | 14 | @selectors = selectors 15 | 16 | return unless parent 17 | raise "Unexpected class #{definition.class.name}" unless definition.is_a?(Activecube::Field) 18 | 19 | field_methods! if definition.class < Activecube::Field 20 | end 21 | 22 | def required_column_names 23 | ((dimension.class.column_names || []) + selectors.map(&:required_column_names)).flatten.uniq 24 | end 25 | 26 | def [](arg) 27 | key = arg.to_sym 28 | 29 | child = if definition.is_a?(Activecube::Dimension) && definition.class.fields && (fdef = definition.class.fields[key]) 30 | Activecube::Field.build key, fdef 31 | elsif definition.is_a?(Activecube::Field) && (hash = definition.definition).is_a?(Hash) 32 | Activecube::Field.build key, hash[key] 33 | end 34 | 35 | raise Activecube::InputArgumentError, "Field #{key} is not defined for #{definition}" unless child 36 | 37 | if child.is_a?(Class) && child <= Activecube::Field 38 | child = child.new key 39 | elsif !child.is_a?(Activecube::Field) 40 | child = Activecube::Field.new(key, child) 41 | end 42 | 43 | Slice.new cube, key, child, self 44 | end 45 | 46 | def alias!(new_key) 47 | self.class.new cube, new_key, definition, parent, selectors 48 | end 49 | 50 | def when(*args) 51 | append(*args, @selectors, Selector, cube.selectors) 52 | end 53 | 54 | def group_by_columns 55 | if dimension.class.identity 56 | ([dimension.class.identity] + dimension.class.column_names).uniq 57 | else 58 | [key] 59 | end 60 | end 61 | 62 | def append_query(model, cube_query, table, query) 63 | query = append_with!(model, cube_query, table, query) 64 | 65 | attr_alias = "`#{key}`" 66 | expr = expression(model, table, self, cube_query) 67 | 68 | if parent || definition.respond_to?(:expression) 69 | expr = process_templates(expr) 70 | end 71 | 72 | query = query.project(expr.as(attr_alias)) 73 | 74 | if dimension.class.identity 75 | expr = dimension.class.identity_expression 76 | group_by_columns.each do |column| 77 | node = if column == dimension.class.identity && expr 78 | Arel.sql(expr).as(column) 79 | else 80 | table[column] 81 | end 82 | 83 | query = query.project(node) unless query.projections.include?(node) 84 | 85 | query = query.group(expr ? column : table[column]) if include_group_by 86 | end 87 | elsif include_group_by 88 | query = query.group(attr_alias) 89 | end 90 | 91 | query = query.order(attr_alias) if cube_query.orderings.empty? 92 | 93 | selectors.each do |selector| 94 | selector.append_query model, cube_query, table, query 95 | end 96 | 97 | query 98 | end 99 | 100 | def process_templates(text) 101 | template = Activecube::Processor::Template.new(text) 102 | return text unless template.template_specified? 103 | 104 | return template.apply_template('any') if include_group_by 105 | 106 | template.apply_template('empty') 107 | end 108 | 109 | def to_s 110 | parent ? "Dimension #{dimension}[#{super}]" : "Dimension #{super}" 111 | end 112 | 113 | def field_methods! 114 | excluded = [:expression] + self.class.instance_methods(false) 115 | definition.class.instance_methods(false).each do |name| 116 | next if excluded.include?(name) 117 | 118 | define_singleton_method name do |*args| 119 | definition.send name, *args 120 | self 121 | end 122 | end 123 | end 124 | 125 | private 126 | 127 | def expression(model, arel_table, slice, cube_query) 128 | if parent || definition.respond_to?(:expression) 129 | Arel.sql(definition.expression(model, arel_table, slice, cube_query)) 130 | else 131 | arel_table[dimension.class.column_name] 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/activecube/query_methods.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/query/cube_query' 2 | 3 | module Activecube 4 | module QueryMethods 5 | attr_reader :database, :role 6 | 7 | %i[slice measure when desc desc_by_integer asc asc_by_integer limit offset group_by].each do |method| 8 | define_method(method) do |*args| 9 | Query::CubeQuery.new(self).send method, *args 10 | end 11 | end 12 | 13 | def connected_to(database: nil, role: nil, &block) 14 | raise Activecube::InputArgumentError, 'Must pass block to method' unless block 15 | 16 | super_model.connected_to(database: database, role: role) do 17 | @database = database 18 | @role = role 19 | yield self 20 | end 21 | end 22 | 23 | private 24 | 25 | def super_model 26 | raise Activecube::InputArgumentError, "No tables specified for cube #{name}" unless models && models.count > 0 27 | 28 | models.collect do |m| 29 | m < View ? m.models : m 30 | end.flatten.uniq.collect do |t| 31 | t.ancestors.select { |c| c < ActiveRecord::Base } 32 | end.transpose.select do |c| 33 | c.uniq.count == 1 34 | end.last.first 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/activecube/selector.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/definition_methods' 2 | 3 | module Activecube 4 | class Selector 5 | extend DefinitionMethods 6 | 7 | def expression(model, arel_table, selector, _cube_query) 8 | op = selector.operator 9 | op.expression model, arel_table[self.class.column_name.to_sym], op.argument 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/activecube/version.rb: -------------------------------------------------------------------------------- 1 | module Activecube 2 | VERSION = '0.1.57' 3 | end 4 | -------------------------------------------------------------------------------- /lib/activecube/view.rb: -------------------------------------------------------------------------------- 1 | require 'activecube/view_definition' 2 | require 'activecube/view_connection' 3 | 4 | module Activecube 5 | class View 6 | extend ViewDefinition 7 | extend ViewConnection 8 | 9 | def model 10 | self.class 11 | end 12 | 13 | def name 14 | model.name 15 | end 16 | 17 | def matches?(query, _measures = query.measures) 18 | true 19 | end 20 | 21 | def measures?(_measure) 22 | true 23 | end 24 | 25 | def query(_cube_query, _measures = _cube_query.measures) 26 | raise "query method have to be implemented in #{name}" 27 | end 28 | 29 | def join(_cube_query, _left_query, _right_query) 30 | raise "join method have to be implemented in #{name}" 31 | end 32 | 33 | private 34 | 35 | def dimension_include_group_by?(slice) 36 | slice.dimension_include_group_by 37 | end 38 | 39 | def any_metrics_specified?(measures) 40 | # that means if there are no measures in the query, we don't need to group by. 41 | return false if measures.first.is_a?(Activecube::Query::MeasureNothing) 42 | 43 | true 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/activecube/view_connection.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Activecube::ViewConnection 4 | attr_reader :connection 5 | 6 | def connect_to(connection) 7 | @connection = connection 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/activecube/view_definition.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module Activecube::ViewDefinition 4 | attr_reader :activecube_indexes, :models, :any_column_tables 5 | 6 | def index(index_name, *args) 7 | (@activecube_indexes ||= []) << Activecube::Processor::Index.new(index_name, *args) 8 | end 9 | 10 | def table(x) 11 | (@models ||= []) << x 12 | end 13 | 14 | def any(column_name, table) 15 | (@any_column_tables ||= []) << {column: column_name, table: table} 16 | self.table table 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /spec/cases/activecube_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Activecube do 2 | before(:all) do 3 | ActiveRecord::MigrationContext.new(MIGRATIONS_PATH, ActiveRecord::Base.connection.schema_migration).up 4 | end 5 | 6 | let(:cube) { Test::TransfersCube } 7 | 8 | context 'context' do 9 | it 'executes in context' do 10 | cube.connected_to(database: :default) do |c| 11 | q = c.measure(:count).query 12 | expect(q.rows.count).to eq(1) 13 | end 14 | end 15 | end 16 | 17 | context 'metrics' do 18 | it 'counts record in cube' do 19 | sql = cube.measure(:count).to_sql 20 | 21 | expect(sql).to eq('SELECT count() AS `count` FROM transfers_currency') 22 | end 23 | 24 | it 'uses alias' do 25 | sql = cube.measure( 26 | 'my.count' => cube.metrics[:count] 27 | ).to_sql 28 | 29 | expect(sql).to eq('SELECT count() AS `my.count` FROM transfers_currency') 30 | end 31 | 32 | it 'accepts alias as symbol' do 33 | sql = cube.measure( 34 | my_count: cube.metrics[:count] 35 | ).to_sql 36 | 37 | expect(sql).to eq('SELECT count() AS `my_count` FROM transfers_currency') 38 | end 39 | 40 | it 'modified by function' do 41 | sql = cube.measure(cube.metrics[:amount].calculate(:maximum)).to_sql 42 | 43 | expect(sql).to eq("SELECT MAX(transfers_currency.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `amount` FROM transfers_currency") 44 | end 45 | 46 | context 'selectors' do 47 | it 'uses selector for metric' do 48 | sql = cube.measure( 49 | my_count: cube.metrics[:count] 50 | .when(cube.selectors[:currency].eq(1)) 51 | ).to_sql 52 | 53 | expect(sql).to eq('SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.currency_id = 1') 54 | end 55 | 56 | it 'fiters all cube by selector' do 57 | sql = cube.measure( 58 | my_count: cube.metrics[:count] 59 | ).when(cube.selectors[:currency].eq(1)).to_sql 60 | 61 | expect(sql).to eq('SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.currency_id = 1') 62 | end 63 | 64 | it 'fiters all cube by date' do 65 | sql = cube.measure( 66 | my_count: cube.metrics[:count] 67 | ).when(cube.selectors[:date].eq(Date.parse('2019-01-01'))).to_sql 68 | 69 | expect(sql).to eq("SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.tx_date = '2019-01-01'") 70 | end 71 | 72 | it 'fiters all cube by gteq / lteq date' do 73 | sql = cube.measure( 74 | my_count: cube.metrics[:count] 75 | ).when(cube.selectors[:date].gt(Date.parse('2019-01-01'))) 76 | .when(cube.selectors[:date].lteq(Date.parse('2019-02-01'))).to_sql 77 | 78 | expect(sql).to eq("SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.tx_date > '2019-01-01' AND transfers_currency.tx_date <= '2019-02-01'") 79 | end 80 | 81 | it 'fiters all cube by since / till date' do 82 | sql = cube.measure( 83 | my_count: cube.metrics[:count] 84 | ).when(cube.selectors[:date].since(Date.parse('2019-01-01'))) 85 | .when(cube.selectors[:date].till(Date.parse('2019-02-01'))).to_sql 86 | 87 | expect(sql).to eq("SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.tx_date >= '2019-01-01' AND transfers_currency.tx_date <= '2019-02-01'") 88 | end 89 | 90 | it 'fiters all cube by between date' do 91 | sql = cube.measure( 92 | my_count: cube.metrics[:count] 93 | ).when(cube.selectors[:date].between(Date.parse('2019-01-01'), Date.parse('2019-02-01'))).to_sql 94 | 95 | expect(sql).to eq("SELECT count() AS `my_count` FROM transfers_currency WHERE transfers_currency.tx_date BETWEEN '2019-01-01' AND '2019-02-01'") 96 | end 97 | 98 | it 'uses multiple selector for metric' do 99 | sql = cube.measure( 100 | my_count: cube.metrics[:count] 101 | .when(cube.selectors[:currency].eq(1)) 102 | .when(cube.selectors[:transfer_from].eq('FROM')) 103 | ).to_sql 104 | 105 | expect(sql).to eq("SELECT count() AS `my_count` FROM transfers_from WHERE transfers_from.currency_id = 1 AND transfers_from.transfer_from_bin = unhex('from')") 106 | end 107 | 108 | it 'uses multiple metrics with separate selectors' do 109 | sql = cube.measure( 110 | count1: cube.metrics[:count] 111 | .when(cube.selectors[:currency].eq(1)), 112 | count2: cube.metrics[:count] 113 | .when(cube.selectors[:currency].eq(2)) 114 | ).to_sql 115 | 116 | expect(sql).to eq('SELECT countIf(transfers_currency.currency_id = 1) AS `count1`, countIf(transfers_currency.currency_id = 2) AS `count2` FROM transfers_currency WHERE (transfers_currency.currency_id = 1 OR transfers_currency.currency_id = 2)') 117 | end 118 | 119 | it 'uses multiple metrics ( sumIf test ) with separate selectors' do 120 | sql = cube.measure( 121 | count1: cube.metrics[:amount] 122 | .when(cube.selectors[:currency].eq(1)), 123 | count2: cube.metrics[:amount] 124 | .when(cube.selectors[:currency].eq(2)) 125 | ).to_sql 126 | 127 | expect(sql).to eq("SELECT sumIf(transfers_currency.value,transfers_currency.currency_id = 1) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `count1`, sumIf(transfers_currency.value,transfers_currency.currency_id = 2) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `count2` FROM transfers_currency WHERE (transfers_currency.currency_id = 1 OR transfers_currency.currency_id = 2)") 128 | end 129 | 130 | it 'uses multiple metrics with separate selectors' do 131 | sql = cube.measure( 132 | count1: cube.metrics[:count] 133 | .when(cube.selectors[:currency].eq(1)), 134 | count2: cube.metrics[:count] 135 | .when(cube.selectors[:currency].eq(2)) 136 | ).to_sql 137 | 138 | expect(sql).to eq('SELECT countIf(transfers_currency.currency_id = 1) AS `count1`, countIf(transfers_currency.currency_id = 2) AS `count2` FROM transfers_currency WHERE (transfers_currency.currency_id = 1 OR transfers_currency.currency_id = 2)') 139 | end 140 | 141 | it 'chains selectors' do 142 | sql = cube.measure( 143 | count1: cube.metrics[:count] 144 | .when(cube.selectors[:currency].eq(1)) 145 | ).measure( 146 | count2: cube.metrics[:count] 147 | .when(cube.selectors[:currency].eq(2)) 148 | ).to_sql 149 | 150 | expect(sql).to eq('SELECT countIf(transfers_currency.currency_id = 1) AS `count1`, countIf(transfers_currency.currency_id = 2) AS `count2` FROM transfers_currency WHERE (transfers_currency.currency_id = 1 OR transfers_currency.currency_id = 2)') 151 | end 152 | end 153 | end 154 | 155 | context 'dimensions' do 156 | it 'slices' do 157 | sql = cube 158 | .measure(:count) 159 | .slice(:currency) 160 | .to_sql 161 | 162 | expect(sql).to eq('SELECT transfers_currency.currency_id AS `currency`, transfers_currency.currency_id, count() AS `count` FROM transfers_currency GROUP BY transfers_currency.currency_id ORDER BY `currency`') 163 | end 164 | 165 | it 'slices with many metrics' do 166 | sql = cube.slice(currency: cube.dimensions[:currency][:symbol]) 167 | .measure(outflow: cube.metrics[:amount].when( 168 | cube.selectors[:transfer_from].eq('1111') 169 | )).measure(inflow: cube.metrics[:amount].when( 170 | cube.selectors[:transfer_to].not_in('1111', '2222') 171 | )).to_sql 172 | 173 | expect(sql).to eq("SELECT * FROM (SELECT dictGetString('currency', 'symbol', toUInt64(currency_id)) AS `currency`, transfers_from.currency_id, SUM(transfers_from.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `outflow` FROM transfers_from WHERE transfers_from.transfer_from_bin = unhex('1111') GROUP BY transfers_from.currency_id ORDER BY `currency`) FULL OUTER JOIN (SELECT dictGetString('currency', 'symbol', toUInt64(currency_id)) AS `currency`, transfers_to.currency_id, SUM(transfers_to.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `inflow` FROM transfers_to WHERE transfers_to.transfer_to_bin NOT IN (unhex('1111'), unhex('2222')) GROUP BY transfers_to.currency_id ORDER BY `currency`) USING currency_id,currency") 174 | end 175 | 176 | it 'use function modifers ( format )' do 177 | sql = cube 178 | .measure(:count) 179 | .slice(date: cube.dimensions[:date][:date].format('%Y-%m')) 180 | .to_sql 181 | 182 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, count() AS `count` FROM transfers_currency GROUP BY `date` ORDER BY `date`") 183 | end 184 | 185 | it 'can slice with no measures' do 186 | sql = cube 187 | .slice(date: cube.dimensions[:date][:date].format('%Y-%m')) 188 | .to_sql 189 | 190 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date` FROM transfers_currency GROUP BY `date` ORDER BY `date`") 191 | end 192 | 193 | it 'uses selector for slice' do 194 | sql = cube 195 | .measure(:count) 196 | .slice(date: cube.dimensions[:date][:date].format('%Y-%m') 197 | .when(cube.selectors[:transfer_to].not_in('1111', '2222'))) 198 | .to_sql 199 | 200 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, count() AS `count` FROM transfers_to WHERE transfers_to.transfer_to_bin NOT IN (unhex('1111'), unhex('2222')) GROUP BY `date` ORDER BY `date`") 201 | end 202 | 203 | it 'use function modifers ( format ) as send' do 204 | sql = cube 205 | .measure(:count) 206 | .slice(date: cube.dimensions[:date][:date].format('%Y-%m')) 207 | .to_sql 208 | 209 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, count() AS `count` FROM transfers_currency GROUP BY `date` ORDER BY `date`") 210 | end 211 | 212 | context 'fields' do 213 | it 'use field class inline' do 214 | sql = cube 215 | .measure(:count) 216 | .slice(date: cube.dimensions[:date][:date_inline].format('%Y-%m')) 217 | .to_sql 218 | 219 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, count() AS `count` FROM transfers_currency GROUP BY `date` ORDER BY `date`") 220 | end 221 | 222 | it 'use field hierarchy' do 223 | sql = cube 224 | .measure(:count) 225 | .slice(year: cube.dimensions[:date][:day][:year][:number]) 226 | .to_sql 227 | 228 | expect(sql).to eq('SELECT toYear(tx_date) AS `year`, count() AS `count` FROM transfers_currency GROUP BY `year` ORDER BY `year`') 229 | end 230 | 231 | it 'use field hierarchy and method' do 232 | sql = cube 233 | .measure(:count) 234 | .slice(date: cube.dimensions[:date][:day][:date][:formatted].format('%Y-%m')) 235 | .to_sql 236 | 237 | expect(sql).to eq("SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, count() AS `count` FROM transfers_currency GROUP BY `date` ORDER BY `date`") 238 | end 239 | end 240 | end 241 | 242 | context 'examples' do 243 | it 'slices by months transfers' do 244 | sql = cube 245 | .slice(date: cube.dimensions[:date][:date].format('%Y-%m')). 246 | 247 | measure(sum_in: cube.metrics[:amount].when( 248 | cube.selectors[:transfer_to].eq('ADR'), 249 | cube.selectors[:currency].eq(1) 250 | )). 251 | 252 | measure(sum_out: cube.metrics[:amount].when( 253 | cube.selectors[:transfer_from].eq('ADR'), 254 | cube.selectors[:currency].eq(1) 255 | )). 256 | 257 | measure(count_in: cube.metrics[:count].when( 258 | cube.selectors[:transfer_to].eq('ADR'), 259 | cube.selectors[:currency].eq(1) 260 | )). 261 | 262 | measure(count_out: cube.metrics[:count].when( 263 | cube.selectors[:transfer_from].eq('ADR'), 264 | cube.selectors[:currency].eq(1) 265 | )).to_sql 266 | 267 | expect(sql).to eq("SELECT * FROM (SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, SUM(transfers_to.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `sum_in`, count() AS `count_in` FROM transfers_to WHERE transfers_to.transfer_to_bin = unhex('adr') AND transfers_to.currency_id = 1 GROUP BY `date` ORDER BY `date`) FULL OUTER JOIN (SELECT formatDateTime(tx_date,'%Y-%m') AS `date`, SUM(transfers_from.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `sum_out`, count() AS `count_out` FROM transfers_from WHERE transfers_from.transfer_from_bin = unhex('adr') AND transfers_from.currency_id = 1 GROUP BY `date` ORDER BY `date`) USING date") 268 | end 269 | 270 | it 'slices by currencies' do 271 | sql = cube 272 | .slice( 273 | date: cube.dimensions[:currency][:symbol] 274 | ) 275 | .slice( 276 | address: cube.dimensions[:currency][:address] 277 | ) 278 | .measure(sum_in: cube.metrics[:amount].when( 279 | cube.selectors[:transfer_to].eq('ADR') 280 | )). 281 | 282 | measure(sum_out: cube.metrics[:amount].when( 283 | cube.selectors[:transfer_from].eq('ADR') 284 | )). 285 | 286 | measure(count_in: cube.metrics[:count].when( 287 | cube.selectors[:transfer_to].eq('ADR') 288 | )). 289 | 290 | measure(count_out: cube.metrics[:count].when( 291 | cube.selectors[:transfer_from].eq('ADR') 292 | )) 293 | .desc(:count_in).desc(:count_out).limit(5).offset(0) 294 | .to_sql 295 | expect(sql).to eq("SELECT * FROM (SELECT dictGetString('currency', 'symbol', toUInt64(currency_id)) AS `date`, transfers_to.currency_id, dictGetString('currency', 'address', toUInt64(currency_id)) AS `address`, SUM(transfers_to.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `sum_in`, count() AS `count_in` FROM transfers_to WHERE transfers_to.transfer_to_bin = unhex('adr') GROUP BY transfers_to.currency_id ORDER BY `date`, `address`) FULL OUTER JOIN (SELECT dictGetString('currency', 'symbol', toUInt64(currency_id)) AS `date`, transfers_from.currency_id, dictGetString('currency', 'address', toUInt64(currency_id)) AS `address`, SUM(transfers_from.value) / dictGet('currency', 'divider', toUInt64(currency_id)) AS `sum_out`, count() AS `count_out` FROM transfers_from WHERE transfers_from.transfer_from_bin = unhex('adr') GROUP BY transfers_from.currency_id ORDER BY `date`, `address`) USING currency_id,date,address ORDER BY `count_in` DESC, `count_out` DESC LIMIT 5 OFFSET 0") 296 | end 297 | end 298 | 299 | context 'options' do 300 | it 'orders and limits' do 301 | sql = cube 302 | .measure(:count) 303 | .slice(:currency) 304 | .asc(:count) 305 | .limit(5) 306 | .offset(5) 307 | .to_sql 308 | 309 | expect(sql).to eq('SELECT transfers_currency.currency_id AS `currency`, transfers_currency.currency_id, count() AS `count` FROM transfers_currency GROUP BY transfers_currency.currency_id ORDER BY `count` ASC LIMIT 5 OFFSET 5') 310 | end 311 | 312 | it 'use offset / limit aliases' do 313 | sql = cube 314 | .measure(:count) 315 | .slice(:currency) 316 | .asc(:count) 317 | .limit(5) 318 | .offset(5) 319 | .to_sql 320 | 321 | expect(sql).to eq('SELECT transfers_currency.currency_id AS `currency`, transfers_currency.currency_id, count() AS `count` FROM transfers_currency GROUP BY transfers_currency.currency_id ORDER BY `count` ASC LIMIT 5 OFFSET 5') 322 | end 323 | 324 | it 'forces ordering if specified' do 325 | sql = cube 326 | .measure(:count) 327 | .slice(year: cube.dimensions[:date][:year]) 328 | .asc('year') 329 | .limit(5) 330 | .offset(5) 331 | .to_sql 332 | 333 | expect(sql).to eq('SELECT toYear(tx_date) AS `year`, count() AS `count` FROM transfers_currency GROUP BY `year` ORDER BY `year` ASC LIMIT 5 OFFSET 5') 334 | end 335 | 336 | it 'ordering case with internal measure reduction' do 337 | sql = cube 338 | .measure(count: cube.metrics[:count].when(cube.selectors[:transfer_from].eq('ADR'))) 339 | .slice(year: cube.dimensions[:date][:year]) 340 | .asc('count') 341 | .to_sql 342 | 343 | expect(sql).to eq("SELECT toYear(tx_date) AS `year`, count() AS `count` FROM transfers_from WHERE transfers_from.transfer_from_bin = unhex('adr') GROUP BY `year` ORDER BY `count` ASC") 344 | end 345 | 346 | it 'ordering case with multiple internal measure reduction' do 347 | sql = cube 348 | .measure(count: cube.metrics[:count].when(cube.selectors[:transfer_from].eq('ADR'))) 349 | .measure(count2: cube.metrics[:count].when(cube.selectors[:transfer_from].eq('ADR2'))) 350 | .slice(year: cube.dimensions[:date][:year]) 351 | .asc('count').limit(2) 352 | .to_sql 353 | 354 | expect(sql).to eq("SELECT toYear(tx_date) AS `year`, countIf(transfers_from.transfer_from_bin = unhex('adr')) AS `count`, countIf(transfers_from.transfer_from_bin = unhex('adr2')) AS `count2` FROM transfers_from WHERE (transfers_from.transfer_from_bin = unhex('adr') OR transfers_from.transfer_from_bin = unhex('adr2')) GROUP BY `year` ORDER BY `count` ASC LIMIT 2") 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /spec/migrations/1_create_transfers_currency_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTransfersCurrencyTable < ActiveRecord::Migration[5.0] 4 | def up 5 | create_table :transfers_currency do |t| 6 | t.date :tx_date 7 | t.datetime :tx_time 8 | 9 | t.string :transfer_from_bin 10 | t.string :transfer_to_bin 11 | t.string :tx_hash_bin 12 | 13 | t.integer :currency_id 14 | 15 | t.float :value 16 | end 17 | 18 | Test::TransfersCurrency.reset_column_information 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/migrations/2_create_transfers_from_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTransfersFromTable < ActiveRecord::Migration[5.0] 4 | def up 5 | create_table :transfers_from do |t| 6 | t.date :tx_date 7 | t.datetime :tx_time 8 | 9 | t.string :transfer_from_bin 10 | t.string :transfer_to_bin 11 | t.string :tx_hash_bin 12 | 13 | t.integer :currency_id 14 | 15 | t.float :value 16 | end 17 | 18 | Test::TransfersFrom.reset_column_information 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/migrations/3_create_transfers_to_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTransfersToTable < ActiveRecord::Migration[5.0] 4 | def up 5 | create_table :transfers_to do |t| 6 | t.date :tx_date 7 | t.datetime :tx_time 8 | 9 | t.string :transfer_from_bin 10 | t.string :transfer_to_bin 11 | t.string :tx_hash_bin 12 | 13 | t.integer :currency_id 14 | 15 | t.float :value 16 | end 17 | 18 | Test::TransfersTo.reset_column_information 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/dimension/currency.rb: -------------------------------------------------------------------------------- 1 | module Dimension 2 | class Currency < Activecube::Dimension 3 | column 'currency_id' 4 | identity_column 'currency_id' 5 | 6 | field 'symbol', "dictGetString('currency', 'symbol', toUInt64(currency_id))" 7 | field 'name', "dictGetString('currency', 'name', toUInt64(currency_id))" 8 | field 'token_id', "dictGetUInt32('currency', 'token_id', toUInt64(currency_id))" 9 | field 'token_type', "dictGetString('currency', 'token_type', toUInt64(currency_id))" 10 | field 'decimals', "dictGetUInt8('currency', 'decimals', toUInt64(currency_id))" 11 | field 'address', "dictGetString('currency', 'address', toUInt64(currency_id))" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/models/dimension/date.rb: -------------------------------------------------------------------------------- 1 | module Dimension 2 | class Date < Activecube::Dimension 3 | DEFAULT_FORMAT = '%Y-%m-%d' 4 | 5 | column 'tx_date' 6 | 7 | field 'month', 'toMonth(tx_date)' 8 | field 'year', 'toYear(tx_date)' 9 | field 'dayOfMonth', 'toDayOfMonth(tx_date)' 10 | field 'dayOfWeek', 'toDayOfWeek(tx_date)' 11 | 12 | field 'date', DateField 13 | 14 | field 'date_inline', (Class.new(Activecube::Field) do 15 | def format(string) 16 | @format = string 17 | end 18 | 19 | def expression(_model, _arel_table, _slice, _cube_query) 20 | "formatDateTime(tx_date,'#{@format || DEFAULT_FORMAT}')" 21 | end 22 | end) 23 | 24 | field 'day', { 25 | year: { 26 | number: 'toYear(tx_date)' 27 | }, 28 | date: { 29 | formatted: (Class.new(Activecube::Field) do 30 | def format(string) 31 | @format = string 32 | end 33 | 34 | def expression(_model, _arel_table, _slice, _cube_query) 35 | "formatDateTime(tx_date,'#{@format || DEFAULT_FORMAT}')" 36 | end 37 | end) 38 | } 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/models/dimension/date_field.rb: -------------------------------------------------------------------------------- 1 | module Dimension 2 | class DateField < Activecube::Field 3 | def format(string) 4 | @format = string 5 | end 6 | 7 | def expression(_model, _arel_table, _slice, _cube_query) 8 | "formatDateTime(tx_date,'#{@format || DEFAULT_FORMAT}')" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/metric/amount.rb: -------------------------------------------------------------------------------- 1 | module Metric 2 | class Amount < Activecube::Metric 3 | include QueryHelper 4 | include Activecube::Common::Metrics 5 | 6 | column 'value' 7 | 8 | modifier :calculate 9 | 10 | def expression(model, arel_table, measure, cube_query) 11 | if calculate = measure.modifier(:calculate) 12 | send(calculate.args.first, model, arel_table, measure, 13 | cube_query) / Arel.sql(dict_currency_divider('currency_id')) 14 | else 15 | sum(model, arel_table, measure, cube_query) / Arel.sql(dict_currency_divider('currency_id')) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/models/metric/count.rb: -------------------------------------------------------------------------------- 1 | module Metric 2 | class Count < Activecube::Metric 3 | include Activecube::Common::Metrics 4 | 5 | def expression(model, arel_table, measure, cube_query) 6 | count(model, arel_table, measure, cube_query) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/query_helper.rb: -------------------------------------------------------------------------------- 1 | module QueryHelper 2 | def dict_currency_divider(expression) 3 | "dictGet('currency', 'divider', toUInt64(#{expression}))" 4 | end 5 | 6 | def unhex_bin(string) 7 | if string.is_a? Array 8 | string.map { |s| unhex_bin s } 9 | elsif string.is_a? String 10 | hex = string.downcase.delete_prefix('0x') 11 | Arel.sql("unhex('#{hex}')") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/models/test/currency_selector.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class CurrencySelector < Activecube::Selector 3 | column 'currency_id' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/test/date_selector.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class DateSelector < Activecube::Selector 3 | include QueryHelper 4 | 5 | column 'tx_date' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/test/transfer_from_selector.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransferFromSelector < Activecube::Selector 3 | include QueryHelper 4 | 5 | column 'transfer_from_bin' 6 | 7 | def expression(model, arel_table, selector, _cube_query) 8 | op = selector.operator 9 | op.expression model, arel_table[self.class.column_name], unhex_bin(op.argument) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/test/transfer_to_selector.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransferToSelector < Activecube::Selector 3 | include QueryHelper 4 | 5 | column 'transfer_to_bin' 6 | 7 | def expression(model, arel_table, selector, _cube_query) 8 | op = selector.operator 9 | op.expression model, arel_table[self.class.column_name], unhex_bin(op.argument) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/test/transfers_cube.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransfersCube < Activecube::Base 3 | table TransfersCurrency 4 | table TransfersFrom 5 | table TransfersTo 6 | 7 | dimension date: Dimension::Date, 8 | currency: Dimension::Currency 9 | 10 | metric amount: Metric::Amount, 11 | count: Metric::Count 12 | 13 | selector currency: CurrencySelector, 14 | transfer_from: TransferFromSelector, 15 | transfer_to: TransferToSelector, 16 | date: DateSelector 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models/test/transfers_currency.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransfersCurrency < ApplicationRecord 3 | self.table_name = 'transfers_currency' 4 | 5 | index 'currency_id', cardinality: 4 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/test/transfers_from.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransfersFrom < ApplicationRecord 3 | self.table_name = 'transfers_from' 4 | 5 | index 'transfer_from_bin', cardinality: 10 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/test/transfers_to.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | class TransfersTo < ApplicationRecord 3 | self.table_name = 'transfers_to' 4 | 5 | index 'transfer_to_bin', cardinality: 10 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'activecube' 3 | 4 | require 'models/application_record' 5 | require 'models/query_helper' 6 | 7 | require 'models/test/transfers_currency' 8 | require 'models/test/transfers_from' 9 | require 'models/test/transfers_to' 10 | 11 | require 'models/test/currency_selector' 12 | require 'models/test/transfer_from_selector' 13 | require 'models/test/transfer_to_selector' 14 | require 'models/test/date_selector' 15 | 16 | require 'models/dimension/currency' 17 | require 'models/dimension/date_field' 18 | require 'models/dimension/date' 19 | 20 | require 'models/metric/amount' 21 | require 'models/metric/count' 22 | 23 | require 'models/test/transfers_cube' 24 | 25 | MIGRATIONS_PATH = File.join(File.dirname(__FILE__), 'migrations') 26 | 27 | RSpec.configure do |config| 28 | # Enable flags like --only-failures and --next-failure 29 | config.example_status_persistence_file_path = '.rspec_status' 30 | 31 | # Disable RSpec exposing methods globally on `Module` and `main` 32 | config.disable_monkey_patching! 33 | 34 | config.expect_with :rspec do |c| 35 | c.syntax = :expect 36 | end 37 | 38 | ActiveRecord::Base.configurations = HashWithIndifferentAccess.new( 39 | default: { 40 | adapter: 'clickhouse', 41 | host: 'clickhouse', 42 | port: 8123, 43 | database: 'test', 44 | username: nil, 45 | password: nil 46 | } 47 | ) 48 | 49 | ActiveRecord::Base.establish_connection(:default) 50 | end 51 | --------------------------------------------------------------------------------