├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .travis ├── create_db_schemas.sh ├── oracle │ ├── download.sh │ └── install.sh └── run_tests.sh ├── Changelog.md ├── Gemfile ├── LICENSE-Mondrian.txt ├── LICENSE.txt ├── README.md ├── RUNNING_TESTS.rdoc ├── Rakefile ├── VERSION ├── appveyor.yml ├── lib ├── mondrian-olap.rb └── mondrian │ ├── jars │ ├── caffeine-2.9.3.jar │ ├── commons-collections-3.2.2.jar │ ├── commons-dbcp-1.4.jar │ ├── commons-io-2.18.0.jar │ ├── commons-lang3-3.17.0.jar │ ├── commons-logging-1.2.jar │ ├── commons-math-1.1.jar │ ├── commons-pool-1.5.7.jar │ ├── commons-vfs2-2.10.0.jar │ ├── eigenbase-properties-1.1.2.jar │ ├── eigenbase-resgen-1.3.1.jar │ ├── eigenbase-xom-1.3.5.jar │ ├── javacup-10k.jar │ ├── log4j-api-2.17.2.jar │ ├── log4j-core-2.17.2.jar │ ├── log4j2-config.jar │ ├── mondrian-9.3.0.0.jar │ └── olap4j-1.2.0.jar │ ├── olap.rb │ └── olap │ ├── connection.rb │ ├── cube.rb │ ├── error.rb │ ├── query.rb │ ├── result.rb │ ├── schema.rb │ ├── schema_element.rb │ ├── schema_udf.rb │ └── version.rb ├── mondrian-olap.gemspec └── spec ├── connection_role_spec.rb ├── connection_spec.rb ├── cube_cache_control_spec.rb ├── cube_spec.rb ├── fixtures ├── MondrianTest.xml └── MondrianTestOracle.xml ├── mondrian_spec.rb ├── query_spec.rb ├── rake_tasks.rb ├── schema_definition_spec.rb ├── spec_helper.rb └── support ├── data └── .gitignore ├── jars └── .gitignore └── matchers └── be_like.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .rvmrc 3 | .DS_Store 4 | .autotest 5 | .ruby-* 6 | coverage 7 | doc 8 | pkg 9 | log 10 | tmp 11 | sqlnet.log 12 | Gemfile.lock 13 | .project 14 | *.gem 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --backtrace 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 1.9 3 | DisabledByDefault: true 4 | 5 | # Prefer &&/|| over and/or. 6 | Style/AndOr: 7 | Enabled: true 8 | 9 | # Do not use braces for hash literals when they are the last argument of a 10 | # method call. 11 | Style/BracesAroundHashParameters: 12 | Enabled: true 13 | 14 | # Align `when` with `case`. 15 | Style/CaseIndentation: 16 | Enabled: true 17 | # align with the final end as case expression result might be assigned to a variable 18 | EnforcedStyle: end 19 | 20 | # Align comments with method definitions. 21 | Style/CommentIndentation: 22 | Enabled: true 23 | 24 | # No extra empty lines. 25 | Style/EmptyLines: 26 | Enabled: true 27 | 28 | # In a regular class definition, no empty lines around the body. 29 | # Style/EmptyLinesAroundClassBody: 30 | # Enabled: true 31 | 32 | # In a regular module definition, no empty lines around the body. 33 | # Style/EmptyLinesAroundModuleBody: 34 | # Enabled: true 35 | 36 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. 37 | # Style/HashSyntax: 38 | # Enabled: true 39 | 40 | # Method definitions after `private` or `protected` isolated calls need one 41 | # extra level of indentation. 42 | Style/IndentationConsistency: 43 | Enabled: true 44 | # EnforcedStyle: rails 45 | EnforcedStyle: normal 46 | 47 | # Two spaces, no tabs (for indentation). 48 | Style/IndentationWidth: 49 | Enabled: true 50 | 51 | Style/SpaceAfterColon: 52 | Enabled: true 53 | 54 | Style/SpaceAfterComma: 55 | Enabled: true 56 | 57 | Style/SpaceAroundEqualsInParameterDefault: 58 | Enabled: true 59 | 60 | Style/SpaceAroundKeyword: 61 | Enabled: true 62 | 63 | Style/SpaceAroundOperators: 64 | Enabled: true 65 | 66 | Style/SpaceBeforeFirstArg: 67 | Enabled: true 68 | 69 | # Defining a method with parameters needs parentheses. 70 | Style/MethodDefParentheses: 71 | Enabled: true 72 | 73 | # Use `foo {}` not `foo{}`. 74 | # Style/SpaceBeforeBlockBraces: 75 | # Enabled: true 76 | 77 | # Use `foo { bar }` not `foo {bar}`. 78 | # Style/SpaceInsideBlockBraces: 79 | # Enabled: true 80 | 81 | # Use `{ a: 1 }` not `{a:1}`. 82 | # Style/SpaceInsideHashLiteralBraces: 83 | # Enabled: true 84 | 85 | Style/SpaceInsideParens: 86 | Enabled: true 87 | 88 | # Check quotes usage according to lint rule below. 89 | # Style/StringLiterals: 90 | # Enabled: true 91 | # EnforcedStyle: double_quotes 92 | 93 | # Detect hard tabs, no hard tabs. 94 | Style/Tab: 95 | Enabled: true 96 | 97 | # Blank lines should not have any spaces. 98 | Style/TrailingBlankLines: 99 | Enabled: true 100 | 101 | # No trailing whitespace. 102 | Style/TrailingWhitespace: 103 | Enabled: true 104 | 105 | # Use quotes for string literals when they are enough. 106 | Style/UnneededPercentQ: 107 | Enabled: true 108 | 109 | # Align `end` with the matching keyword or starting expression except for 110 | # assignments, where it should be aligned with the LHS. 111 | Lint/EndAlignment: 112 | Enabled: true 113 | EnforcedStyleAlignWith: variable 114 | 115 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 116 | Lint/RequireParentheses: 117 | Enabled: true 118 | 119 | #################### Metrics ############################### 120 | 121 | Metrics/BlockLength: 122 | Enabled: true 123 | Max: 30 124 | 125 | Metrics/BlockNesting: 126 | Enabled: true 127 | Max: 3 128 | 129 | Metrics/ClassLength: 130 | Enabled: true 131 | Max: 300 132 | 133 | # Avoid complex methods. 134 | Metrics/CyclomaticComplexity: 135 | Enabled: true 136 | Max: 10 137 | 138 | Metrics/LineLength: 139 | Enabled: true 140 | Max: 130 141 | 142 | Metrics/MethodLength: 143 | Enabled: true 144 | Max: 30 145 | 146 | Metrics/ModuleLength: 147 | Enabled: true 148 | Max: 300 149 | 150 | Metrics/ParameterLists: 151 | Enabled: true 152 | Max: 5 153 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: focal 3 | 4 | language: ruby 5 | cache: 6 | - bundler 7 | notifications: 8 | email: false 9 | rvm: 10 | - jruby-9.4.12.0 11 | addons: 12 | hosts: 13 | - oracle.vm 14 | apt: 15 | packages: 16 | - haveged 17 | services: 18 | - mysql 19 | - postgresql 20 | jdk: 21 | - openjdk8 22 | - openjdk11 23 | - openjdk17 24 | env: 25 | global: 26 | - ORACLE_COOKIE=sqldev 27 | - ORACLE_FILE=oracle11g/xe/oracle-xe-11.2.0-1.0.x86_64.rpm.zip 28 | - ORACLE_HOME=/u01/app/oracle/product/11.2.0/xe 29 | - TNS_ADMIN=$ORACLE_HOME/network/admin 30 | - NLS_LANG=AMERICAN_AMERICA.AL32UTF8 31 | - ORACLE_BASE=/u01/app/oracle 32 | - LD_LIBRARY_PATH=$ORACLE_HOME/lib 33 | - PATH=$PATH:$ORACLE_HOME/jdbc/lib 34 | - DATABASE_VERSION=11.2.0.2 35 | - ORACLE_SID=XE 36 | - ORACLE_DATABASE_NAME=XE 37 | - DATABASE_NON_DEFAULT_TABLESPACE=USERS 38 | - JAVA_OPTS=-Xmx512m 39 | - JRUBY_OPTS="--dev" 40 | branches: 41 | only: 42 | - master 43 | 44 | before_install: 45 | - gem install bundler 46 | 47 | install: 48 | - .travis/oracle/download.sh 49 | - .travis/oracle/install.sh 50 | - bundle install --jobs=3 --retry=3 --path vendor/bundle --binstubs 51 | 52 | before_script: 53 | - .travis/create_db_schemas.sh 54 | 55 | script: 56 | - .travis/run_tests.sh 57 | -------------------------------------------------------------------------------- /.travis/create_db_schemas.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ev 4 | 5 | mysql -e 'CREATE DATABASE IF NOT EXISTS mondrian_test DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;' 6 | mysql -e "CREATE USER IF NOT EXISTS 'mondrian_test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'mondrian_test';" 7 | mysql -e "GRANT ALL PRIVILEGES ON mondrian_test.* TO 'mondrian_test'@'localhost';" 8 | 9 | psql -c "CREATE ROLE mondrian_test PASSWORD 'mondrian_test' LOGIN CREATEDB;" 10 | psql -c 'CREATE DATABASE mondrian_test;' 11 | psql -c 'GRANT ALL PRIVILEGES ON DATABASE mondrian_test TO mondrian_test;' 12 | 13 | "$ORACLE_HOME/bin/sqlplus" -L -S / AS SYSDBA < "${deb_file}" 11 | 12 | pwd 13 | 14 | ls -lAh "${deb_file}" 15 | -------------------------------------------------------------------------------- /.travis/oracle/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [ -n "$ORACLE_FILE" ] || { echo "Missing ORACLE_FILE environment variable!"; exit 1; } 4 | [ -n "$ORACLE_HOME" ] || { echo "Missing ORACLE_HOME environment variable!"; exit 1; } 5 | 6 | cd "$(dirname "$(readlink -f "$0")")" 7 | 8 | ORACLE_DEB=docker-oracle-xe-11g/assets/oracle-xe_11.2.0-1.0_amd64.deb 9 | 10 | sudo apt-get -qq update 11 | sudo apt-get --no-install-recommends -qq install bc libaio1 12 | 13 | df -B1 /dev/shm | awk 'END { if ($1 != "shmfs" && $1 != "tmpfs" || $2 < 2147483648) exit 1 }' || 14 | ( sudo rm -r /dev/shm && sudo mkdir /dev/shm && sudo mount -t tmpfs shmfs -o size=2G /dev/shm ) 15 | 16 | test -f /sbin/chkconfig || 17 | ( echo '#!/bin/sh' | sudo tee /sbin/chkconfig > /dev/null && sudo chmod u+x /sbin/chkconfig ) 18 | 19 | test -d /var/lock/subsys || sudo mkdir /var/lock/subsys 20 | 21 | sudo dpkg -i "${ORACLE_DEB}" 22 | 23 | echo 'OS_AUTHENT_PREFIX=""' | sudo tee -a "$ORACLE_HOME/config/scripts/init.ora" > /dev/null 24 | sudo usermod -aG dba $USER 25 | 26 | ( echo ; echo ; echo travis ; echo travis ; echo n ) | sudo AWK='/usr/bin/awk' /etc/init.d/oracle-xe configure 27 | 28 | "$ORACLE_HOME/bin/sqlplus" -L -S / AS SYSDBA <true)` will return formatted results 172 | * set Unicode encoding for mysql connection 173 | * `format_string` attribute and `formula` element for calculated members 174 | * `:use_content_checksum` connection option (by default set to true) 175 | * `key_expression`, `name_expression`, `ordinal_expression` elements with `sql` subelement support for levels 176 | * `:upcase_data_dictionary` option for schema definition 177 | * Bug fixes 178 | * fixed examples in README 179 | * correctly quote `CatalogContent` in connection string (to allow usage of semicolons in generated XML catalog) 180 | * workarounds for issues with Java classloader when using in production mode with jruby-rack 181 | * construct correct file path on Windows 182 | 183 | ### 0.1.0 / 2011-03-18 184 | 185 | * Initial release 186 | * support for MySQL, PostgreSQL and Oracle databases 187 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby RUBY_VERSION, engine: 'jruby', engine_version: JRUBY_VERSION 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE-Mondrian.txt: -------------------------------------------------------------------------------- 1 | Eclipse Public License -v 1.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and documentation distributed under this Agreement, and 10 | 11 | b) in the case of each subsequent Contributor: 12 | 13 | i) changes to the Program, and 14 | 15 | ii) additions to the Program; 16 | 17 | where such changes and/or additions to the Program originate from and are distributed by that particular Contributor. A Contribution 'originates' from a Contributor if it was added to the Program by such Contributor itself or anyone acting on such Contributor's behalf. Contributions do not include additions to the Program which: (i) are separate modules of software distributed in conjunction with the Program under their own license agreement, and (ii) are not derivative works of the Program. 18 | 19 | "Contributor" means any person or entity that distributes the Program. 20 | 21 | "Licensed Patents " mean patent claims licensable by a Contributor which are necessarily infringed by the use or sale of its Contribution alone or when combined with the Program. 22 | 23 | "Program" means the Contributions distributed in accordance with this Agreement. 24 | 25 | "Recipient" means anyone who receives the Program under this Agreement, including all Contributors. 26 | 27 | 2. GRANT OF RIGHTS 28 | 29 | a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute and sublicense the Contribution of such Contributor, if any, and such derivative works, in source code and object code form. 30 | 31 | b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use, sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any, in source code and object code form. This patent license shall apply to the combination of the Contribution and the Program if, at the time the Contribution is added by the Contributor, such addition of the Contribution causes such combination to be covered by the Licensed Patents. The patent license shall not apply to any other combinations which include the Contribution. No hardware per se is licensed hereunder. 32 | 33 | c) Recipient understands that although each Contributor grants the licenses to its Contributions set forth herein, no assurances are provided by any Contributor that the Program does not infringe the patent or other intellectual property rights of any other entity. Each Contributor disclaims any liability to Recipient for claims brought by any other entity based on infringement of intellectual property rights or otherwise. As a condition to exercising the rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to secure any other intellectual property rights needed, if any. For example, if a third party patent license is required to allow Recipient to distribute the Program, it is Recipient's responsibility to acquire that license before distributing the Program. 34 | 35 | d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its Contribution, if any, to grant the copyright license set forth in this Agreement. 36 | 37 | 3. REQUIREMENTS 38 | 39 | A Contributor may choose to distribute the Program in object code form under its own license agreement, provided that: 40 | 41 | a) it complies with the terms and conditions of this Agreement; and 42 | 43 | b) its license agreement: 44 | 45 | i) effectively disclaims on behalf of all Contributors all warranties and conditions, express and implied, including warranties or conditions of title and non-infringement, and implied warranties or conditions of merchantability and fitness for a particular purpose; 46 | 47 | ii) effectively excludes on behalf of all Contributors all liability for damages, including direct, indirect, special, incidental and consequential damages, such as lost profits; 48 | 49 | iii) states that any provisions which differ from this Agreement are offered by that Contributor alone and not by any other party; and 50 | 51 | iv) states that source code for the Program is available from such Contributor, and informs licensees how to obtain it in a reasonable manner on or through a medium customarily used for software exchange. 52 | 53 | When the Program is made available in source code form: 54 | 55 | a) it must be made available under this Agreement; and 56 | 57 | b) a copy of this Agreement must be included with each copy of the Program. 58 | 59 | Contributors may not remove or alter any copyright notices contained within the Program. 60 | 61 | Each Contributor must identify itself as the originator of its Contribution, if any, in a manner that reasonably allows subsequent Recipients to identify the originator of the Contribution. 62 | 63 | 4. COMMERCIAL DISTRIBUTION 64 | 65 | Commercial distributors of software may accept certain responsibilities with respect to end users, business partners and the like. While this license is intended to facilitate the commercial use of the Program, the Contributor who includes the Program in a commercial product offering should do so in a manner which does not create potential liability for other Contributors. Therefore, if a Contributor includes the Program in a commercial product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and indemnify every other Contributor ("Indemnified Contributor") against any losses, damages and costs (collectively "Losses") arising from claims, lawsuits and other legal actions brought by a third party against the Indemnified Contributor to the extent caused by the acts or omissions of such Commercial Contributor in connection with its distribution of the Program in a commercial product offering. The obligations in this section do not apply to any claims or Losses relating to any actual or alleged intellectual property infringement. In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial Contributor in writing of such claim, and b) allow the Commercial Contributor to control, and cooperate with the Commercial Contributor in, the defense and any related settlement negotiations. The Indemnified Contributor may participate in any such claim at its own expense. 66 | 67 | For example, a Contributor might include the Program in a commercial product offering, Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor then makes performance claims, or offers warranties related to Product X, those performance claims and warranties are such Commercial Contributor's responsibility alone. Under this section, the Commercial Contributor would have to defend claims against the other Contributors related to those performance claims and warranties, and if a court requires any other Contributor to pay any damages as a result, the Commercial Contributor must pay those damages. 68 | 69 | 5. NO WARRANTY 70 | 71 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the appropriateness of using and distributing the Program and assumes all risks associated with its exercise of rights under this Agreement , including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and unavailability or interruption of operations. 72 | 73 | 6. DISCLAIMER OF LIABILITY 74 | 75 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 76 | 77 | 7. GENERAL 78 | 79 | If any provision of this Agreement is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this Agreement, and without further action by the parties hereto, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. 80 | 81 | If Recipient institutes patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the Program with other software or hardware) infringes such Recipient's patent(s), then such Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation is filed. 82 | 83 | All Recipient's rights under this Agreement shall terminate if it fails to comply with any of the material terms or conditions of this Agreement and does not cure such failure in a reasonable period of time after becoming aware of such noncompliance. If all Recipient's rights under this Agreement terminate, Recipient agrees to cease use and distribution of the Program as soon as reasonably practicable. However, Recipient's obligations under this Agreement and any licenses granted by Recipient relating to the Program shall continue and survive. 84 | 85 | Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid inconsistency the Agreement is copyrighted and may only be modified in the following manner. The Agreement Steward reserves the right to publish new versions (including revisions) of this Agreement from time to time. No one other than the Agreement Steward has the right to modify this Agreement. The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation may assign the responsibility to serve as the Agreement Steward to a suitable separate entity. Each new version of the Agreement will be given a distinguishing version number. The Program (including Contributions) may always be distributed subject to the version of the Agreement under which it was received. In addition, after a new version of the Agreement is published, Contributor may elect to distribute the Program (including its Contributions) under the new version. Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to the intellectual property of any Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise. All rights in the Program not expressly granted under this Agreement are reserved. 86 | 87 | This Agreement is governed by the laws of the State of New York and the intellectual property laws of the United States of America. No party to this Agreement will bring a legal action under this Agreement more than one year after the cause of action arose. Each party waives its rights to a jury trial in any resulting litigation. 88 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2010-2021 Raimonds Simanovskis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis CI status](https://travis-ci.org/rsim/mondrian-olap.svg?branch=master)](https://travis-ci.org/rsim/mondrian-olap) 2 | [![AppVeyor status](https://ci.appveyor.com/api/projects/status/08xd4tyty2k3wxba/branch/master?svg=true)](https://ci.appveyor.com/project/rsim/mondrian-olap) 3 | 4 | mondrian-olap 5 | ============= 6 | 7 | JRuby gem for performing multidimensional queries of relational database data using Mondrian OLAP Java library. 8 | 9 | DESCRIPTION 10 | ----------- 11 | 12 | SQL language is good for doing ad-hoc queries from relational databases but it becomes very complicated when doing more complex analytical queries to get summary results. Alternative approach is OLAP (On-Line Analytical Processing) databases and engines that provide easier multidimensional analysis of data at different summary levels. 13 | 14 | One of the most popular open-source OLAP engines is [Mondrian](http://mondrian.pentaho.com). Mondrian OLAP engine can be put in front of relational SQL database and it provides MDX multidimensional query language which is much more suited for analytical purposes. 15 | 16 | mondrian-olap is JRuby gem which includes Mondrian OLAP engine and provides Ruby DSL for creating OLAP schemas on top of relational database schemas and provides MDX query language and query builder Ruby methods for making analytical queries. 17 | 18 | mondrian-olap is used in [eazyBI data analysis and reporting web application](https://eazybi.com). [Private eazyBI](https://eazybi.com/help/private-eazybi) can be used to create easy-to-use web based reports and dashboards on top of mondrian-olap based backend database. There is also [mondrian-olap demo Rails application for trying MDX queries](https://github.com/rsim/mondrian_demo). The [mondrian-rest](https://github.com/jazzido/mondrian-rest) uses mondrian-olap to implement a REST API interface for a Mondrian schema. 19 | 20 | USAGE 21 | ----- 22 | 23 | ### Schema definition 24 | 25 | At first you need to define OLAP schema mapping to relational database schema tables and columns. OLAP schema consists of: 26 | 27 | * Cubes 28 | 29 | Multidimensional cube is a collection of measures that can be accessed by dimensions. In relational database cubes are stored in fact tables with measure columns and dimension foreign key columns. 30 | 31 | * Dimensions 32 | 33 | Dimension can be used in one cube (private) or in many cubes (shared). In relational database dimensions are stored in dimension tables. 34 | 35 | * Hierarchies and levels 36 | 37 | Dimension has at least one primary hierarchy and optional additional hierarchies and each hierarchy has one or more levels. In relational database all levels can be stored in the same dimension table as different columns or can be stored also in several tables. 38 | 39 | * Members 40 | 41 | Dimension hierarchy level values are called members. 42 | 43 | * Measures 44 | 45 | Measures are values which can be accessed at detailed level or aggregated (e.g. as sum or average) at higher dimension hierarchy levels. In relational database measures are stored as columns in cube table. 46 | 47 | * Calculated measures 48 | 49 | Calculated measures are not stored in database but calculated using specified formula from other measures. 50 | 51 | Read more about about [defining Mondrian OLAP schema](http://mondrian.pentaho.com/documentation/schema.php). 52 | 53 | Here is example how to define OLAP schema and its mapping to relational database tables and columns using mondrian-olap: 54 | 55 | ```ruby 56 | require "rubygems" 57 | require "mondrian-olap" 58 | 59 | schema = Mondrian::OLAP::Schema.define do 60 | cube 'Sales' do 61 | table 'sales' 62 | dimension 'Customers', foreign_key: 'customer_id' do 63 | hierarchy has_all: true, all_member_name: 'All Customers', primary_key: 'id' do 64 | table 'customers' 65 | level 'Country', column: 'country', unique_members: true 66 | level 'State Province', column: 'state_province', unique_members: true 67 | level 'City', column: 'city', unique_members: false 68 | level 'Name', column: 'fullname', unique_members: true 69 | end 70 | end 71 | dimension 'Products', foreign_key: 'product_id' do 72 | hierarchy has_all: true, all_member_name: 'All Products', 73 | primary_key: 'id', primary_key_table: 'products' do 74 | join left_key: 'product_class_id', right_key: 'id' do 75 | table 'products' 76 | table 'product_classes' 77 | end 78 | level 'Product Family', table: 'product_classes', column: 'product_family', unique_members: true 79 | level 'Brand Name', table: 'products', column: 'brand_name', unique_members: false 80 | level 'Product Name', table: 'products', column: 'product_name', unique_members: true 81 | end 82 | end 83 | dimension 'Time', foreign_key: 'time_id', type: 'TimeDimension' do 84 | hierarchy has_all: false, primary_key: 'id' do 85 | table 'time' 86 | level 'Year', column: 'the_year', type: 'Numeric', unique_members: true, level_type: 'TimeYears' 87 | level 'Quarter', column: 'quarter', unique_members: false, level_type: 'TimeQuarters' 88 | level 'Month', column: 'month_of_year', type: 'Numeric', unique_members: false, level_type: 'TimeMonths' 89 | end 90 | hierarchy 'Weekly', has_all: false, primary_key: 'id' do 91 | table 'time' 92 | level 'Year', column: 'the_year', type: 'Numeric', unique_members: true, level_type: 'TimeYears' 93 | level 'Week', column: 'week_of_year', type: 'Numeric', unique_members: false, level_type: 'TimeWeeks' 94 | end 95 | end 96 | measure 'Unit Sales', column: 'unit_sales', aggregator: 'sum' 97 | measure 'Store Sales', column: 'store_sales', aggregator: 'sum' 98 | end 99 | end 100 | ``` 101 | 102 | ### Connection creation 103 | 104 | When schema is defined it is necessary to establish OLAP connection to database. Here is example how to connect to MySQL database using the schema object that was defined previously: 105 | 106 | ```ruby 107 | require "jdbc/mysql" 108 | 109 | olap = Mondrian::OLAP::Connection.create( 110 | driver: 'mysql', 111 | host: 'localhost', 112 | database: 'mondrian_test', 113 | username: 'mondrian_user', 114 | password: 'secret', 115 | schema: schema 116 | ) 117 | ``` 118 | 119 | ### MDX queries 120 | 121 | Mondrian OLAP provides MDX query language. [Read more about MDX](http://mondrian.pentaho.com/documentation/mdx.php). 122 | mondrian-olap allows executing of MDX queries, for example query for "Get sales amount and number of units (on columns) of all product families (on rows) sold in California during Q1 of 2010": 123 | 124 | ```ruby 125 | result = olap.execute <<-MDX 126 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 127 | {[Products].children} ON ROWS 128 | FROM [Sales] 129 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 130 | MDX 131 | ``` 132 | 133 | which would correspond to the following SQL query: 134 | 135 | SELECT SUM(unit_sales) unit_sales_sum, SUM(store_sales) store_sales_sum 136 | FROM sales 137 | LEFT JOIN products ON sales.product_id = products.id 138 | LEFT JOIN product_classes ON products.product_class_id = product_classes.id 139 | LEFT JOIN time ON sales.time_id = time.id 140 | LEFT JOIN customers ON sales.customer_id = customers.id 141 | WHERE time.the_year = 2010 AND time.quarter = 'Q1' 142 | AND customers.country = 'USA' AND customers.state_province = 'CA' 143 | GROUP BY product_classes.product_family 144 | ORDER BY product_classes.product_family 145 | 146 | and then get axis and cells of result object: 147 | 148 | ```ruby 149 | result.axes_count # => 2 150 | result.column_names # => ["Unit Sales", "Store Sales"] 151 | result.column_full_names # => ["[Measures].[Unit Sales]", "[Measures].[Store Sales]"] 152 | result.row_names # => e.g. ["Drink", "Food", "Non-Consumable"] 153 | result.row_full_names # => e.g. ["[Products].[Drink]", "[Products].[Food]", "[Products].[Non-Consumable]"] 154 | result.values # => [[..., ...], [..., ...], [..., ...]] 155 | # (three rows, each row containing value for "unit sales" and "store sales") 156 | ``` 157 | 158 | ### Query builder methods 159 | 160 | MDX queries could be built and executed also using Ruby methods in a similar way as ActiveRecord/Arel queries are made. 161 | Previous MDX query can be executed as: 162 | 163 | ```ruby 164 | olap.from('Sales'). 165 | columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 166 | rows('[Products].children'). 167 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 168 | execute 169 | ``` 170 | 171 | Here is example of more complex query "Get sales amount and profit % of top 50 products cross-joined with USA and Canada country sales during Q1 of 2010": 172 | 173 | ```ruby 174 | olap.from('Sales'). 175 | with_member('[Measures].[ProfitPct]'). 176 | as('Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 177 | format_string: 'Percent'). 178 | columns('[Measures].[Store Sales]', '[Measures].[ProfitPct]'). 179 | rows('[Products].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 180 | top_count(50, '[Measures].[Store Sales]'). 181 | where('[Time].[2010].[Q1]'). 182 | execute 183 | ``` 184 | 185 | See more examples of queries in `spec/query_spec.rb`. 186 | 187 | Currently there are query builder methods just for most frequently used MDX functions, there will be new query builder methods in next releases of mondrian-olap gem. 188 | 189 | ### Cube dimension and member queries 190 | 191 | mondrian-olap provides also methods for querying dimensions and members: 192 | 193 | ```ruby 194 | cube = olap.cube('Sales') 195 | cube.dimension_names # => ['Measures', 'Customers', 'Products', 'Time'] 196 | cube.dimensions # => array of dimension objects 197 | cube.dimension('Customers') # => customers dimension object 198 | cube.dimension('Time').hierarchy_names # => ['Time', 'Time.Weekly'] 199 | cube.dimension('Time').hierarchies # => array of hierarchy objects 200 | cube.dimension('Customers').hierarchy # => default customers dimension hierarchy 201 | cube.dimension('Customers').hierarchy.level_names 202 | # => ['(All)', 'Country', 'State Province', 'City', 'Name'] 203 | cube.dimension('Customers').hierarchy.levels 204 | # => array of hierarchy level objects 205 | cube.dimension('Customers').hierarchy.level('Country').members 206 | # => array of all level members 207 | cube.member('[Customers].[USA].[CA]') # => lookup member by full name 208 | cube.member('[Customers].[USA].[CA]').children 209 | # => get all children of member in deeper hierarchy level 210 | cube.member('[Customers].[USA]').descendants_at_level('City') 211 | # => get all descendants of member in specified hierarchy level 212 | ``` 213 | 214 | See more examples of dimension and member queries in `spec/cube_spec.rb`. 215 | 216 | ### User defined MDX functions 217 | 218 | You can define new MDX functions using Ruby that you can later use either in calculated member formulas or in MDX queries. 219 | Here are examples of user defined functions in Ruby: 220 | 221 | ```ruby 222 | schema = Mondrian::OLAP::Schema.define do 223 | # ... cube definitions ... 224 | user_defined_function 'Factorial' do 225 | ruby do 226 | parameters :numeric 227 | returns :numeric 228 | def call(n) 229 | n <= 1 ? 1 : n * call(n - 1) 230 | end 231 | end 232 | end 233 | user_defined_function 'UpperName' do 234 | ruby do 235 | parameters :member 236 | returns :string 237 | syntax :property 238 | def call(member) 239 | member.getName.upcase 240 | end 241 | end 242 | end 243 | end 244 | ``` 245 | 246 | See more examples of user defined functions in `spec/schema_definition_spec.rb`. 247 | 248 | ### Data access roles 249 | 250 | In schema you can define data access roles which can be selected for connection and which will limit access just to 251 | subset of measures and dimension members. Here is example of data access role definition: 252 | 253 | ```ruby 254 | schema = Mondrian::OLAP::Schema.define do 255 | # ... cube definitions ... 256 | role 'California manager' do 257 | schema_grant access: 'none' do 258 | cube_grant cube: 'Sales', access: 'all' do 259 | dimension_grant dimension: '[Measures]', access: 'all' 260 | hierarchy_grant hierarchy: '[Customers]', access: 'custom', 261 | top_level: '[Customers].[State Province]', bottom_level: '[Customers].[City]' do 262 | member_grant member: '[Customers].[USA].[CA]', access: 'all' 263 | member_grant member: '[Customers].[USA].[CA].[Los Angeles]', access: 'none' 264 | end 265 | end 266 | end 267 | end 268 | end 269 | ``` 270 | 271 | See more examples of data access roles in `spec/connection_role_spec.rb`. 272 | 273 | REQUIREMENTS 274 | ------------ 275 | 276 | mondrian-olap gem is compatible with JRuby versions 9.3.x and 9.4.x, JVM 8, 11, and 17. mondrian-olap works only with JRuby and not with other Ruby implementations as it includes Mondrian OLAP Java libraries. 277 | 278 | mondrian-olap supports MySQL, PostgreSQL, Oracle, Microsoft SQL Server, Vertica, Snowflake, and ClickHouse databases as well as other databases that are supported by Mondrian OLAP engine (using jdbc_driver and jdbc_url connection parameters). When using MySQL or PostgreSQL databases then install jdbc-mysql or jdbc-postgres gem and require "jdbc/mysql" or "jdbc/postgres" to load the corresponding JDBC database driver. When using Oracle then require Oracle JDBC driver `ojdbc*.jar`. When using MS SQL Server you then use the Microsoft JDBC driver `mssql-jdbc-*.jar`. When using Vertica, Snowflake, or ClickHouse then require corresponding JDBC drivers. 279 | 280 | INSTALL 281 | ------- 282 | 283 | Install gem with: 284 | 285 | gem install mondrian-olap 286 | 287 | or include in your project's Gemfile: 288 | 289 | gem "mondrian-olap" 290 | 291 | LINKS 292 | ----- 293 | 294 | * Source code: http://github.com/rsim/mondrian-olap 295 | * Bug reports / Feature requests: http://github.com/rsim/mondrian-olap/issues 296 | * General discussions and questions at: http://groups.google.com/group/mondrian-olap 297 | * mondrian-olap demo Rails application: https://github.com/rsim/mondrian_demo 298 | 299 | LICENSE 300 | ------- 301 | 302 | mondrian-olap is released under the terms of MIT license; see LICENSE.txt. 303 | 304 | Mondrian OLAP Engine is released under the terms of the Eclipse Public 305 | License v1.0 (EPL); see LICENSE-Mondrian.html. 306 | -------------------------------------------------------------------------------- /RUNNING_TESTS.rdoc: -------------------------------------------------------------------------------- 1 | == Creating test database 2 | 3 | By default unit tests use MySQL database but PostgreSQL, Oracle and SQL Server databases are supported as well. Set MONDRIAN_DRIVER environment variable to "mysql" (default), "postgresql", "oracle", "sqlserver" (Microsoft JDBC), "vertica", "snowflake", "clickhouse", "mariadb" to specify database driver that should be used. 4 | 5 | If using a MySQL, PostgreSQL or MS SQL Server database then create database user mondrian_test with password mondrian_test, create database mondrian_test and grant full access to this database for mondrian_test user. By default it is assumed that database is located on localhost (can be overridden with DATABASE_HOST environment variable). 6 | 7 | If using Oracle database then create database user mondrian_test with password mondrian_test. By default it is assumed that database orcl is located on localhost (can be overridden with DATABASE_NAME and DATABASE_HOST environment variables). 8 | 9 | if using Vertica then specify environment variables VERTICA_DATABASE_HOST, VERTICA_DATABASE_NAME, VERTICA_DATABASE_USER, VERTICA_DATABASE_PASSWORD and create the "mondrian_test" schema. 10 | 11 | If using Snowflake then create MONDRIAN_TEST user, database and schema and specify environment variables SNOWFLAKE_DATABASE_HOST and SNOWFLAKE_DATABASE_PASSWORD. 12 | 13 | If using ClickHouse then create database user mondrian_test with password mondrian_test, create database mondrian_test and grant full access to this database for mondrian_test user. Specify environment variable CLICKHOUSE_DATABASE_HOST. Download the latest clickhouse-jdbc-*-shaded.jar and copy to spec/support/jars. 14 | 15 | If using MariaDB ColumnStore then create database user mondrian_test with password mondrian_test, create database mondrian_test and grant full access to this database for mondrian_test user. Specify environment variable MARIADB_DATABASE_HOST. Download mariadb-java-client-*.jar and copy to spec/support/jars. 16 | 17 | See spec/spec_helper.rb for details of default connection parameters and how to override them. 18 | 19 | == Creating test data 20 | 21 | Install necessary gems with 22 | 23 | bundle install 24 | 25 | Create tables with test data using 26 | 27 | rake db:create_data 28 | 29 | or specify which database driver to use 30 | 31 | rake db:create_data MONDRIAN_DRIVER=mysql 32 | rake db:create_data MONDRIAN_DRIVER=postgresql 33 | rake db:create_data MONDRIAN_DRIVER=oracle 34 | rake db:create_data MONDRIAN_DRIVER=sqlserver 35 | 36 | In case of Vertica, Snowflake, ClickHouse, MariaDB ColumnStore at first create test data in MySQL and export test data into CSV files and then import CSV files into the analytical database (because inserting individual records into these analytical databases is very slow): 37 | 38 | rake db:export_data MONDRIAN_DRIVER=mysql 39 | rake db:create_data MONDRIAN_DRIVER=vertica 40 | rake db:create_data MONDRIAN_DRIVER=snowflake 41 | rake db:create_data MONDRIAN_DRIVER=clickhouse 42 | rake db:create_data MONDRIAN_DRIVER=mariadb 43 | 44 | == Running tests 45 | 46 | Run tests with 47 | 48 | rake spec 49 | 50 | or specify which database driver to use 51 | 52 | rake spec MONDRIAN_DRIVER=mysql 53 | rake spec MONDRIAN_DRIVER=postgresql 54 | rake spec MONDRIAN_DRIVER=oracle 55 | rake spec MONDRIAN_DRIVER=sqlserver 56 | rake spec MONDRIAN_DRIVER=vertica 57 | rake spec MONDRIAN_DRIVER=snowflake 58 | rake spec MONDRIAN_DRIVER=clickhouse 59 | rake spec MONDRIAN_DRIVER=mariadb 60 | 61 | or also alternatively with 62 | 63 | rake spec:mysql 64 | rake spec:postgresql 65 | rake spec:oracle 66 | rake spec:sqlserver 67 | rake spec:vertica 68 | rake spec:snowflake 69 | rake spec:clickhouse 70 | rake spec:mariadb 71 | 72 | You can also run all tests on all standard databases (mysql jdbc_mysql postgresql oracle sqlserver) with 73 | 74 | rake spec:all 75 | 76 | == JRuby versions 77 | 78 | It is recommended to use RVM (http://rvm.beginrescueend.com) to run tests with different JRuby implementations. mondrian-olap is being tested with latest versions of JRuby 9.3 and 9.4 on JVM 8, 11, and 17. 79 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | desc "Run specs" 5 | RSpec::Core::RakeTask.new(:spec) 6 | RSpec::Core::RakeTask.new(:rcov) do |t| 7 | t.rcov = true 8 | t.rcov_opts = ['--exclude', '/Library,spec/'] 9 | end 10 | 11 | desc "Run specs (default)" 12 | task :default => :spec 13 | 14 | require 'rdoc/task' 15 | RDoc::Task.new do |rdoc| 16 | version = File.exist?('VERSION') ? File.read('VERSION').chomp : "" 17 | 18 | rdoc.rdoc_dir = 'doc' 19 | rdoc.title = "mondrian-olap #{version}" 20 | rdoc.rdoc_files.include('README*') 21 | rdoc.rdoc_files.include('lib/**/*.rb') 22 | end 23 | 24 | require_relative 'spec/rake_tasks' 25 | 26 | Dir["lib/tasks/**/*.rake"].each { |ext| load ext } if defined?(Rake) 27 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.0 2 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | JRUBY_OPTS: '--dev -J-Xmx1024m' 3 | matrix: 4 | - JRUBY_VERSION: "9.3.13.0" 5 | JAVA_VERSION: jdk11 6 | 7 | services: 8 | - mssql2016 9 | 10 | build: off 11 | 12 | branches: 13 | only: 14 | - master 15 | 16 | install: 17 | - ps: secedit /export /cfg c:\secpol.cfg 18 | - ps: (gc C:\secpol.cfg).replace("PasswordComplexity = 1", "PasswordComplexity = 0").replace("MinimumPasswordLength = 8", "MinimumPasswordLength = 0") | Out-File C:\secpol.cfg 19 | - ps: secedit /configure /db c:\windows\security\local.sdb /cfg c:\secpol.cfg /areas SECURITYPOLICY 20 | - ps: rm -force c:\secpol.cfg -confirm:$false 21 | - appveyor DownloadFile https://s3.amazonaws.com/jruby.org/downloads/%JRUBY_VERSION%/jruby-bin-%JRUBY_VERSION%.zip 22 | - 7z x jruby-bin-%JRUBY_VERSION%.zip -y > nul 23 | - del jruby-bin-%JRUBY_VERSION%.zip 24 | - appveyor DownloadFile https://download.microsoft.com/download/4/D/C/4DCD85FA-0041-4D2E-8DD9-833C1873978C/sqljdbc_7.2.1.0_enu.exe 25 | - 7z x sqljdbc_7.2.1.0_enu.exe -y > nul 26 | - copy sqljdbc_7.2\enu\mssql-jdbc-7.2.1.jre8.jar spec\support\jars\ 27 | - SET JAVA_HOME=C:\Program Files\Java\%JAVA_VERSION% 28 | - SET PATH=C:\projects\mondrian-olap\jruby-%JRUBY_VERSION%\bin;%JAVA_HOME%\bin;%PATH% 29 | - gem install bundler 30 | - bundle install --jobs=1 --retry=3 31 | 32 | before_test: 33 | - jruby -v 34 | - gem -v 35 | - bundle -v 36 | - sqlcmd -S "(local)" -U "sa" -P "Password12!" -Q "CREATE LOGIN mondrian_test WITH PASSWORD = 'mondrian_test'" 37 | - sqlcmd -S "(local)" -U "sa" -P "Password12!" -Q "ALTER SERVER ROLE [dbcreator] ADD MEMBER [mondrian_test]" 38 | - sqlcmd -S "(local)" -U "mondrian_test" -P "mondrian_test" -Q "CREATE DATABASE mondrian_test" 39 | - bundle exec rake db:create_data MONDRIAN_DRIVER=sqlserver 40 | 41 | test_script: 42 | - bundle exec rake spec MONDRIAN_DRIVER=sqlserver 43 | -------------------------------------------------------------------------------- /lib/mondrian-olap.rb: -------------------------------------------------------------------------------- 1 | require 'mondrian/olap' 2 | -------------------------------------------------------------------------------- /lib/mondrian/jars/caffeine-2.9.3.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/caffeine-2.9.3.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-collections-3.2.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-collections-3.2.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-dbcp-1.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-dbcp-1.4.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-io-2.18.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-io-2.18.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-lang3-3.17.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-lang3-3.17.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-logging-1.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-logging-1.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-math-1.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-math-1.1.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-pool-1.5.7.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-pool-1.5.7.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-vfs2-2.10.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/commons-vfs2-2.10.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-properties-1.1.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/eigenbase-properties-1.1.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-resgen-1.3.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/eigenbase-resgen-1.3.1.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-xom-1.3.5.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/eigenbase-xom-1.3.5.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/javacup-10k.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/javacup-10k.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/log4j-api-2.17.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/log4j-api-2.17.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/log4j-core-2.17.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/log4j-core-2.17.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/log4j2-config.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/log4j2-config.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/mondrian-9.3.0.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/mondrian-9.3.0.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/olap4j-1.2.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsim/mondrian-olap/3aab3c764f6b1520c4f4cc55bb9423866422d7cd/lib/mondrian/jars/olap4j-1.2.0.jar -------------------------------------------------------------------------------- /lib/mondrian/olap.rb: -------------------------------------------------------------------------------- 1 | require 'java' 2 | require 'nokogiri' 3 | 4 | { 5 | # Do not register MondrianOlap4jDriver 6 | "mondrian.olap4j.registerDriver" => false, 7 | # Do not register log3j2 MBean 8 | "log4j2.disable.jmx" => true 9 | }.each do |key, value| 10 | if java.lang.System.getProperty(key).nil? 11 | java.lang.System.setProperty(key, value.to_s) 12 | end 13 | end 14 | 15 | directory = File.expand_path("../jars", __FILE__) 16 | Dir["#{directory}/*.jar"].each do |file| 17 | require file 18 | end 19 | 20 | %w(error connection query result schema schema_udf cube).each do |file| 21 | require "mondrian/olap/#{file}" 22 | end 23 | -------------------------------------------------------------------------------- /lib/mondrian/olap/connection.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | class Connection 4 | def self.create(params) 5 | connection = new(params) 6 | connection.connect 7 | connection 8 | end 9 | 10 | attr_reader :raw_connection, :raw_mondrian_connection, :raw_catalog, :raw_schema, 11 | :raw_schema_reader, :raw_cache_control 12 | 13 | def initialize(params = {}) 14 | @params = params 15 | @driver = params[:driver] 16 | @connected = false 17 | @raw_connection = nil 18 | end 19 | 20 | def connect 21 | Error.wrap_native_exception do 22 | # hack to call private constructor of MondrianOlap4jDriver 23 | # to avoid using DriverManager which fails to load JDBC drivers 24 | # because of not seeing JRuby required jar files 25 | cons = Java::MondrianOlap4j::MondrianOlap4jDriver.java_class.declared_constructor 26 | cons.accessible = true 27 | driver = cons.new_instance.to_java 28 | 29 | props = java.util.Properties.new 30 | props.setProperty('JdbcUser', @params[:username]) if @params[:username] 31 | props.setProperty('JdbcPassword', @params[:password]) if @params[:password] 32 | 33 | # on Oracle increase default row prefetch size 34 | # as default 10 is very low and slows down loading of all dimension members 35 | if @driver == 'oracle' 36 | prefetch_rows = @params[:prefetch_rows] || 100 37 | props.setProperty("jdbc.defaultRowPrefetch", prefetch_rows.to_s) 38 | end 39 | 40 | conn_string = connection_string 41 | 42 | # latest Mondrian version added ClassResolver which uses current thread class loader to load some classes 43 | # therefore need to set it to JRuby class loader to ensure that Mondrian classes are found 44 | # (e.g. when running mondrian-olap inside OSGi container) 45 | current_thread = Java::JavaLang::Thread.currentThread 46 | class_loader = current_thread.getContextClassLoader 47 | begin 48 | current_thread.setContextClassLoader JRuby.runtime.jruby_class_loader 49 | @raw_jdbc_connection = driver.connect(conn_string, props) 50 | ensure 51 | current_thread.setContextClassLoader(class_loader) 52 | end 53 | 54 | @raw_connection = @raw_jdbc_connection.unwrap(Java::OrgOlap4j::OlapConnection.java_class) 55 | @raw_catalog = @raw_connection.getOlapCatalog 56 | # currently it is assumed that there is just one schema per connection catalog 57 | @raw_schema = @raw_catalog.getSchemas.first 58 | @raw_mondrian_connection = @raw_connection.getMondrianConnection 59 | @raw_schema_reader = @raw_mondrian_connection.getSchemaReader 60 | @raw_cache_control = @raw_mondrian_connection.getCacheControl(nil) 61 | @connected = true 62 | true 63 | end 64 | end 65 | 66 | def connected? 67 | @connected 68 | end 69 | 70 | def close 71 | @raw_jdbc_connection = @raw_catalog = @raw_schema = @raw_mondrian_connection = nil 72 | @raw_schema_reader = @raw_cache_control = nil 73 | @raw_connection.close 74 | @raw_connection = nil 75 | @connected = false 76 | true 77 | end 78 | 79 | def execute(query_string, parameters = {}) 80 | options = {} 81 | Error.wrap_native_exception(options) do 82 | start_time = Time.now 83 | statement = @raw_connection.prepareOlapStatement(query_string) 84 | options[:profiling_statement] = statement if parameters[:profiling] 85 | set_statement_parameters(statement, parameters) 86 | raw_cell_set = statement.executeQuery() 87 | total_duration = ((Time.now - start_time) * 1000).to_i 88 | Result.new(self, raw_cell_set, profiling_handler: statement.getProfileHandler, total_duration: total_duration) 89 | end 90 | end 91 | 92 | # access mondrian.olap.Parameter object 93 | def mondrian_parameter(parameter_name) 94 | Error.wrap_native_exception do 95 | @raw_schema_reader.getParameter(parameter_name) 96 | end 97 | end 98 | 99 | def execute_drill_through(query_string) 100 | Error.wrap_native_exception do 101 | statement = @raw_connection.createStatement 102 | Result::DrillThrough.new(statement.executeQuery(query_string)) 103 | end 104 | end 105 | 106 | def parse_expression(expression) 107 | Error.wrap_native_exception do 108 | raw_mondrian_connection.parseExpression(expression) 109 | end 110 | end 111 | 112 | def from(cube_name) 113 | Query.from(self, cube_name) 114 | end 115 | 116 | def raw_schema_key 117 | @raw_mondrian_connection.getSchema.getKey 118 | end 119 | 120 | def schema_key 121 | raw_schema_key.toString 122 | end 123 | 124 | def self.raw_schema_key(schema_key) 125 | if schema_key =~ /\A<(.*), (.*)>\z/ 126 | schema_content_key = $1 127 | connection_key = $2 128 | 129 | cons = Java::mondrian.rolap.SchemaContentKey.java_class.declared_constructor(java.lang.String) 130 | cons.accessible = true 131 | raw_schema_content_key = cons.new_instance(schema_content_key) 132 | 133 | cons = Java::mondrian.rolap.ConnectionKey.java_class.declared_constructor(java.lang.String) 134 | cons.accessible = true 135 | raw_connection_key = cons.new_instance(connection_key) 136 | 137 | cons = Java::mondrian.rolap.SchemaKey.java_class.declared_constructor( 138 | Java::mondrian.rolap.SchemaContentKey, Java::mondrian.rolap.ConnectionKey) 139 | cons.accessible = true 140 | cons.new_instance(raw_schema_content_key, raw_connection_key) 141 | else 142 | raise ArgumentError, "invalid schema key #{schema_key}" 143 | end 144 | end 145 | 146 | def cube_names 147 | @raw_schema.getCubes.map{|c| c.getName} 148 | end 149 | 150 | def cube(name) 151 | Cube.get(self, name) 152 | end 153 | 154 | # Will affect only the next created connection. If it is necessary to clear all schema cache then 155 | # flush_schema_cache should be called, then close and then new connection should be created. 156 | # This method flushes schemas for all connections (clears the schema pool). 157 | def flush_schema_cache 158 | raw_cache_control.flushSchemaCache 159 | end 160 | 161 | def self.raw_schema_pool 162 | method = Java::mondrian.rolap.RolapSchemaPool.java_class.declared_method('instance') 163 | method.accessible = true 164 | method.invoke_static 165 | end 166 | 167 | def self.flush_schema_cache 168 | method = Java::mondrian.rolap.RolapSchemaPool.java_class.declared_method('clear') 169 | method.accessible = true 170 | method.invoke(raw_schema_pool) 171 | end 172 | 173 | # This method flushes the schema only for this connection (removes from the schema pool). 174 | def flush_schema 175 | if raw_mondrian_connection && (rolap_schema = raw_mondrian_connection.getSchema) 176 | raw_cache_control.flushSchema(rolap_schema) 177 | end 178 | end 179 | 180 | def self.flush_schema(schema_key) 181 | method = Java::mondrian.rolap.RolapSchemaPool.java_class.declared_method('remove', 182 | Java::mondrian.rolap.SchemaKey.java_class) 183 | method.accessible = true 184 | method.invoke(raw_schema_pool, raw_schema_key(schema_key)) 185 | end 186 | 187 | def available_role_names 188 | @raw_connection.getAvailableRoleNames.to_a 189 | end 190 | 191 | def role_name 192 | @raw_connection.getRoleName 193 | end 194 | 195 | def role_names 196 | # workaround to access non-public method (was not public when using inside Torquebox) 197 | # @raw_connection.getRoleNames.to_a 198 | @raw_connection.java_method(:getRoleNames).call.to_a 199 | end 200 | 201 | def role_name=(name) 202 | Error.wrap_native_exception do 203 | @raw_connection.setRoleName(name) 204 | end 205 | end 206 | 207 | def role_names=(names) 208 | Error.wrap_native_exception do 209 | # workaround to access non-public method (was not public when using inside Torquebox) 210 | # @raw_connection.setRoleNames(Array(names)) 211 | names = Array(names) 212 | @raw_connection.java_method(:setRoleNames, [Java::JavaUtil::List.java_class]).call(names) 213 | names 214 | end 215 | end 216 | 217 | def locale 218 | @raw_connection.getLocale.toString 219 | end 220 | 221 | def locale=(locale) 222 | locale_elements = locale.to_s.split('_') 223 | raise ArgumentError, "invalid locale string #{locale.inspect}" unless [1, 2, 3].include?(locale_elements.length) 224 | java_locale = Java::JavaUtil::Locale.new(*locale_elements) 225 | @raw_connection.setLocale(java_locale) 226 | end 227 | 228 | # access MondrianServer instance 229 | def mondrian_server 230 | Error.wrap_native_exception do 231 | @raw_connection.getMondrianConnection.getServer 232 | end 233 | end 234 | 235 | # Force shutdown of static MondrianServer, should not normally be used. 236 | # Can be used in at_exit block if JRuby based plugin is unloaded from other Java application. 237 | # WARNING: Mondrian will be unusable after calling this method! 238 | def self.shutdown_static_mondrian_server! 239 | static_mondrian_server = Java::MondrianOlap::MondrianServer.forId(nil) 240 | 241 | # force Mondrian to think that static_mondrian_server is not static MondrianServer 242 | mondrian_server_registry = Java::MondrianServer::MondrianServerRegistry::INSTANCE 243 | f = mondrian_server_registry.java_class.declared_field("staticServer") 244 | f.accessible = true 245 | f.set_value(mondrian_server_registry, nil) 246 | 247 | static_mondrian_server.shutdown 248 | 249 | # shut down expiring reference timer thread 250 | f = Java::MondrianUtil::ExpiringReference.java_class.declared_field("timer") 251 | f.accessible = true 252 | expiring_reference_timer = f.static_value.to_java 253 | expiring_reference_timer.cancel 254 | 255 | # shut down Mondrian Monitor 256 | cons = Java::MondrianServer.__send__(:"MonitorImpl$ShutdownCommand").java_class.declared_constructor 257 | cons.accessible = true 258 | shutdown_command = cons.new_instance.to_java 259 | 260 | cons = Java::MondrianServer.__send__(:"MonitorImpl$Handler").java_class.declared_constructor 261 | cons.accessible = true 262 | handler = cons.new_instance.to_java 263 | 264 | pair = Java::mondrian.util.Pair.new handler, shutdown_command 265 | 266 | f = Java::MondrianServer::MonitorImpl.java_class.declared_field("ACTOR") 267 | f.accessible = true 268 | monitor_actor = f.static_value.to_java 269 | 270 | f = monitor_actor.java_class.declared_field("eventQueue") 271 | f.accessible = true 272 | event_queue = f.value(monitor_actor) 273 | 274 | event_queue.put pair 275 | 276 | # shut down connection pool thread 277 | f = Java::mondrian.rolap.RolapConnectionPool.java_class.declared_field("instance") 278 | f.accessible = true 279 | rolap_connection_pool = f.static_value.to_java 280 | f = rolap_connection_pool.java_class.declared_field("mapConnectKeyToPool") 281 | f.accessible = true 282 | map_connect_key_to_pool = f.value(rolap_connection_pool) 283 | map_connect_key_to_pool.values.each do |pool| 284 | pool.close if pool && !pool.isClosed 285 | end 286 | 287 | # unregister MBean 288 | mbs = Java::JavaLangManagement::ManagementFactory.getPlatformMBeanServer 289 | mbean_name = Java::JavaxManagement::ObjectName.new("mondrian.server:type=Server-#{static_mondrian_server.getId}") 290 | begin 291 | mbs.unregisterMBean(mbean_name) 292 | rescue Java::JavaxManagement::InstanceNotFoundException 293 | end 294 | 295 | true 296 | end 297 | 298 | def jdbc_uri 299 | if respond_to?(method_name = "jdbc_uri_#{@driver}", true) 300 | send method_name 301 | else 302 | raise ArgumentError, 'unknown JDBC driver' 303 | end 304 | end 305 | 306 | private 307 | 308 | def connection_string 309 | string = "jdbc:mondrian:Jdbc=#{quote_string(jdbc_uri)};JdbcDrivers=#{jdbc_driver};" 310 | # by default use content checksum to reload schema when catalog has changed 311 | string += "UseContentChecksum=true;" unless @params[:use_content_checksum] == false 312 | string += "PinSchemaTimeout=#{@params[:pin_schema_timeout]};" if @params[:pin_schema_timeout] 313 | if role = @params[:role] || @params[:roles] 314 | roles = Array(role).map{|r| r && r.to_s.gsub(',', ',,')}.compact 315 | string += "Role=#{quote_string(roles.join(','))};" unless roles.empty? 316 | end 317 | if locale = @params[:locale] 318 | string += "Locale=#{quote_string(locale.to_s)};" 319 | end 320 | string + (@params[:catalog] ? "Catalog=#{catalog_uri}" : "CatalogContent=#{quote_string(catalog_content)}") 321 | end 322 | 323 | def jdbc_uri_generic(options = {}) 324 | uri_prefix = options[:uri_prefix] || "jdbc:#{@driver}://" 325 | port = @params[:port] || options[:default_port] 326 | uri = "#{uri_prefix}#{@params[:host]}#{port && ":#{port}"}" 327 | uri += "/#{@params[:database]}" if @params[:database] && options[:add_database] != false 328 | properties = new_empty_properties 329 | properties.merge!(options[:default_properties]) if options[:default_properties].is_a?(Hash) 330 | properties.merge!(@params[:properties]) if @params[:properties].is_a?(Hash) 331 | "#{uri}#{uri_properties_string(properties, options[:separator], options[:first_separator])}" 332 | end 333 | 334 | def new_empty_properties 335 | # If ActiveSupport::HashWithIndifferentAccess is present then treat symbol and string keys as equal 336 | defined?(ActiveSupport::HashWithIndifferentAccess) ? ActiveSupport::HashWithIndifferentAccess.new : {} 337 | end 338 | 339 | def uri_properties_string(properties, separator = nil, first_separator = nil) 340 | properties_string = properties.map { |k, v| "#{k}=#{v}" }.join(separator || '&') 341 | unless properties_string.empty? 342 | first_separator ||= '?' 343 | "#{first_separator}#{properties_string}" 344 | end 345 | end 346 | 347 | def jdbc_uri_mysql 348 | jdbc_uri_generic(default_properties: {useUnicode: true, characterEncoding: 'UTF-8'}) 349 | end 350 | 351 | alias_method :jdbc_uri_postgresql, :jdbc_uri_generic 352 | alias_method :jdbc_uri_vertica, :jdbc_uri_generic 353 | alias_method :jdbc_uri_mariadb, :jdbc_uri_generic 354 | 355 | def jdbc_uri_oracle 356 | # connection using TNS alias 357 | if @params[:database] && !@params[:host] && !@params[:url] && ENV['TNS_ADMIN'] 358 | "jdbc:oracle:thin:@#{@params[:database]}" 359 | else 360 | @params[:url] || begin 361 | database = @params[:database] 362 | unless database =~ %r{^(:|/)} 363 | # assume database is a SID if no colon or slash are supplied (backward-compatibility) 364 | database = ":#{database}" 365 | end 366 | "jdbc:oracle:thin:@#{@params[:host] || 'localhost'}:#{@params[:port] || 1521}#{database}" 367 | end 368 | end 369 | end 370 | 371 | JDBC_SQLSERVER_PARAM_PROPERTIES = { 372 | database: 'databaseName', 373 | integrated_security: 'integratedSecurity', 374 | application_name: 'applicationName', 375 | instance_name: 'instanceName', 376 | instance: 'instanceName' 377 | } 378 | 379 | def jdbc_uri_sqlserver 380 | jdbc_uri_generic( 381 | uri_prefix: 'jdbc:sqlserver://', add_database: false, separator: ';', first_separator: ';', 382 | default_properties: uri_default_param_properties(JDBC_SQLSERVER_PARAM_PROPERTIES) 383 | ) 384 | end 385 | 386 | def uri_default_param_properties(param_properties) 387 | default_properties = {} 388 | param_properties.each do |key, property| 389 | if value = @params[key] 390 | default_properties[property] = value 391 | end 392 | end 393 | default_properties 394 | end 395 | 396 | JDBC_SNOWFLAKE_PARAM_PROPERTIES = { 397 | database: 'db', 398 | database_schema: 'schema', 399 | warehouse: 'warehouse' 400 | } 401 | 402 | def jdbc_uri_snowflake 403 | jdbc_uri_generic( 404 | add_database: false, separator: '&', first_separator: '/?', 405 | default_properties: uri_default_param_properties(JDBC_SNOWFLAKE_PARAM_PROPERTIES) 406 | ) 407 | end 408 | 409 | def jdbc_uri_clickhouse 410 | protocol_prefix = if protocol = @params[:protocol] 411 | raise ArgumentError, "invalid protocol #{protocol}" unless protocol =~ /\A\w+\z/ 412 | ":#{protocol}" 413 | end 414 | uri_prefix = "jdbc:ch#{protocol_prefix}://" 415 | jdbc_uri_generic(uri_prefix: uri_prefix) 416 | end 417 | 418 | def jdbc_uri_jdbc 419 | @params[:jdbc_url] or raise ArgumentError, 'missing jdbc_url parameter' 420 | end 421 | 422 | JDBC_DRIVER_CLASS = { 423 | 'postgresql' => 'org.postgresql.Driver', 424 | 'oracle' => 'oracle.jdbc.OracleDriver', 425 | 'sqlserver' => 'com.microsoft.sqlserver.jdbc.SQLServerDriver', 426 | 'vertica' => 'com.vertica.jdbc.Driver', 427 | 'snowflake' => 'net.snowflake.client.jdbc.SnowflakeDriver', 428 | 'clickhouse' => 'com.clickhouse.jdbc.ClickHouseDriver', 429 | 'mariadb' => 'org.mariadb.jdbc.Driver' 430 | } 431 | 432 | def jdbc_driver 433 | case @driver 434 | when 'mysql' 435 | (Java::com.mysql.cj.jdbc.Driver rescue nil) ? 'com.mysql.cj.jdbc.Driver' : 'com.mysql.jdbc.Driver' 436 | when 'jdbc' 437 | @params[:jdbc_driver] or raise ArgumentError, 'missing jdbc_driver parameter' 438 | else 439 | JDBC_DRIVER_CLASS[@driver] or raise ArgumentError, 'unknown JDBC driver' 440 | end 441 | end 442 | 443 | def catalog_uri 444 | if @params[:catalog] 445 | "file://#{File.expand_path(@params[:catalog])}" 446 | else 447 | raise ArgumentError, 'missing catalog source' 448 | end 449 | end 450 | 451 | def catalog_content 452 | if @params[:catalog_content] 453 | @params[:catalog_content] 454 | elsif @params[:schema] 455 | @params[:schema].to_xml(:driver => @driver) 456 | else 457 | raise ArgumentError, "Specify catalog with :catalog, :catalog_content or :schema option" 458 | end 459 | end 460 | 461 | def quote_string(string) 462 | "'#{string.gsub("'", "''")}'" 463 | end 464 | 465 | def set_statement_parameters(statement, parameters) 466 | if parameters && !parameters.empty? 467 | parameters = parameters.dup 468 | # define addtional parameters which can be accessed from user defined functions 469 | if define_parameters = parameters.delete(:define_parameters) 470 | query_validator = statement.getQuery.createValidator 471 | define_parameters.each do |dp_name, dp_value| 472 | dp_type_class = dp_value.is_a?(Numeric) ? Java::MondrianOlapType::NumericType : Java::MondrianOlapType::StringType 473 | query_validator.createOrLookupParam(true, dp_name, dp_type_class.new, nil, nil) 474 | parameters[dp_name] = dp_value 475 | end 476 | end 477 | if parameters.delete(:profiling) 478 | statement.enableProfiling(ProfilingHandler.new) 479 | end 480 | if timeout = parameters.delete(:timeout) 481 | statement.getQuery.setQueryTimeoutMillis(timeout * 1000) 482 | end 483 | parameters.each do |parameter_name, value| 484 | statement.getQuery.setParameter(parameter_name, value) 485 | end 486 | end 487 | end 488 | 489 | # Starting from Mondrian 9.2 additional QueryBody plan string is added at the end which will be ignored. 490 | QUERY_BODY_PLAN_REGEXP = /\AQueryBody:/ 491 | 492 | class ProfilingHandler 493 | java_implements Java::mondrian.spi.ProfileHandler 494 | attr_reader :plan 495 | attr_reader :timing 496 | 497 | java_signature 'void explain(String plan, mondrian.olap.QueryTiming timing)' 498 | def explain(plan, timing) 499 | if @plan 500 | @plan += "\n" + plan unless plan =~ QUERY_BODY_PLAN_REGEXP 501 | else 502 | @plan = plan 503 | end 504 | @timing = timing 505 | end 506 | end 507 | 508 | end 509 | end 510 | end 511 | -------------------------------------------------------------------------------- /lib/mondrian/olap/cube.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Mondrian 4 | module OLAP 5 | module Annotated 6 | private 7 | 8 | def annotations_for(raw_element) 9 | @annotations ||= begin 10 | annotated = raw_element.unwrap(Java::MondrianOlap::Annotated.java_class) 11 | annotations_hash = annotated.getAnnotationMap.to_hash 12 | annotations_hash.each do |key, annotation| 13 | annotations_hash[key] = annotation.getValue 14 | end 15 | annotations_hash 16 | end 17 | end 18 | end 19 | 20 | class Cube 21 | extend Forwardable 22 | 23 | def self.get(connection, name) 24 | if raw_cube = connection.raw_schema.getCubes.get(name) 25 | Cube.new(connection, raw_cube) 26 | end 27 | end 28 | 29 | def initialize(connection, raw_cube) 30 | @connection = connection 31 | @raw_cube = raw_cube 32 | @cache_control = CacheControl.new(@connection, self) 33 | end 34 | 35 | attr_reader :connection, :raw_cube 36 | 37 | def name 38 | @name ||= @raw_cube.getName 39 | end 40 | 41 | def description 42 | @description ||= @raw_cube.getDescription 43 | end 44 | 45 | def caption 46 | @caption ||= @raw_cube.getCaption 47 | end 48 | 49 | include Annotated 50 | def annotations 51 | annotations_for(@raw_cube) 52 | end 53 | 54 | def visible? 55 | @raw_cube.isVisible 56 | end 57 | 58 | def dimensions 59 | @dimenstions ||= @raw_cube.getDimensions.map { |d| dimension_from_raw(d) } 60 | end 61 | 62 | def dimension_names 63 | dimensions.map(&:name) 64 | end 65 | 66 | def dimension(name) 67 | if @dimensions 68 | @dimensions.detect { |d| d.name == name } 69 | elsif raw_dimension = @raw_cube.getDimensions.detect { |d| d.getName == name } 70 | dimension_from_raw(raw_dimension) 71 | end 72 | end 73 | 74 | def hierarchies 75 | @hierarchies ||= @raw_cube.getHierarchies.map { |h| hierarchy_from_raw(h) } 76 | end 77 | 78 | def hierarchy_names 79 | hierarchies.map(&:name) 80 | end 81 | 82 | def hierarchy(name) 83 | if @hierarchies 84 | @hierarchies.detect { |h| h.name == name } 85 | elsif raw_hierarchy = @raw_cube.getHierarchies.detect { |h| h.getName == name } 86 | hierarchy_from_raw(raw_hierarchy) 87 | end 88 | end 89 | 90 | def query 91 | Query.from(@connection, name) 92 | end 93 | 94 | def member(full_name) 95 | segment_list = Java::OrgOlap4jMdx::IdentifierNode.parseIdentifier(full_name).getSegmentList 96 | raw_member = @raw_cube.lookupMember(segment_list) 97 | raw_member && Member.new(raw_member) 98 | end 99 | 100 | def member_by_segments(*segment_names) 101 | segment_list = Java::OrgOlap4jMdx::IdentifierNode.ofNames(*segment_names).getSegmentList 102 | raw_member = @raw_cube.lookupMember(segment_list) 103 | raw_member && Member.new(raw_member) 104 | end 105 | 106 | def_delegators :@cache_control, :flush_region_cache_with_segments, :flush_region_cache_with_segments 107 | def_delegators :@cache_control, :flush_region_cache_with_full_names, :flush_region_cache_with_full_names 108 | 109 | private 110 | 111 | def dimension_from_raw(raw_dimension) 112 | Dimension.new(self, raw_dimension) 113 | end 114 | 115 | def hierarchy_from_raw(raw_hierarchy) 116 | Hierarchy.new(dimension_from_raw(raw_hierarchy.getDimension), raw_hierarchy) 117 | end 118 | end 119 | 120 | class Dimension 121 | def initialize(cube, raw_dimension) 122 | @cube = cube 123 | @raw_dimension = raw_dimension 124 | end 125 | 126 | attr_reader :cube, :raw_dimension 127 | 128 | def name 129 | @name ||= @raw_dimension.getName 130 | end 131 | 132 | def description 133 | @description ||= @raw_dimension.getDescription 134 | end 135 | 136 | def caption 137 | @caption ||= @raw_dimension.getCaption 138 | end 139 | 140 | def full_name 141 | @full_name ||= @raw_dimension.getUniqueName 142 | end 143 | 144 | def hierarchies 145 | @hierarchies ||= @raw_dimension.getHierarchies.map{|h| Hierarchy.new(self, h)} 146 | end 147 | 148 | def hierarchy_names 149 | hierarchies.map{|h| h.name} 150 | end 151 | 152 | def hierarchy(name = nil) 153 | name ||= self.name 154 | hierarchies.detect{|h| h.name == name} 155 | end 156 | 157 | def measures? 158 | @raw_dimension.getDimensionType == Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 159 | end 160 | 161 | def dimension_type 162 | case @raw_dimension.getDimensionType 163 | when Java::OrgOlap4jMetadata::Dimension::Type::TIME 164 | :time 165 | when Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 166 | :measures 167 | else 168 | :standard 169 | end 170 | end 171 | 172 | include Annotated 173 | def annotations 174 | annotations_for(@raw_dimension) 175 | end 176 | 177 | def visible? 178 | @raw_dimension.isVisible 179 | end 180 | 181 | end 182 | 183 | class Hierarchy 184 | def initialize(dimension, raw_hierarchy) 185 | @dimension = dimension 186 | @raw_hierarchy = raw_hierarchy 187 | end 188 | 189 | attr_reader :raw_hierarchy, :dimension 190 | 191 | def name 192 | @name ||= @raw_hierarchy.getName 193 | end 194 | 195 | def description 196 | @description ||= @raw_hierarchy.getDescription 197 | end 198 | 199 | def caption 200 | @caption ||= @raw_hierarchy.getCaption 201 | end 202 | 203 | def dimension_name 204 | @dimension.name 205 | end 206 | 207 | def levels 208 | @levels = @raw_hierarchy.getLevels.map{|l| Level.new(self, l)} 209 | end 210 | 211 | def level(name) 212 | levels.detect{|l| l.name == name} 213 | end 214 | 215 | def level_names 216 | levels.map{|l| l.name} 217 | end 218 | 219 | def has_all? 220 | @raw_hierarchy.hasAll 221 | end 222 | 223 | def all_member_name 224 | has_all? ? @raw_hierarchy.getRootMembers.first.getName : nil 225 | end 226 | 227 | def all_member 228 | has_all? ? Member.new(@raw_hierarchy.getRootMembers.first) : nil 229 | end 230 | 231 | def root_members 232 | @raw_hierarchy.getRootMembers.map{|m| Member.new(m)} 233 | end 234 | 235 | def root_member_names 236 | @raw_hierarchy.getRootMembers.map{|m| m.getName} 237 | end 238 | 239 | def root_member_full_names 240 | @raw_hierarchy.getRootMembers.map{|m| m.getUniqueName} 241 | end 242 | 243 | def child_names(*parent_member_segment_names) 244 | Error.wrap_native_exception do 245 | parent_member = if parent_member_segment_names.empty? 246 | return root_member_names unless has_all? 247 | all_member 248 | else 249 | @dimension.cube.member_by_segments(*parent_member_segment_names) 250 | end 251 | parent_member && parent_member.children.map{|m| m.name} 252 | end 253 | end 254 | 255 | include Annotated 256 | def annotations 257 | annotations_for(@raw_hierarchy) 258 | end 259 | 260 | def visible? 261 | @raw_hierarchy.isVisible 262 | end 263 | 264 | end 265 | 266 | class Level 267 | def initialize(hierarchy, raw_level) 268 | @hierarchy = hierarchy 269 | @raw_level = raw_level 270 | end 271 | 272 | attr_reader :raw_level 273 | 274 | def name 275 | @name ||= @raw_level.getName 276 | end 277 | 278 | def description 279 | @description ||= @raw_level.getDescription 280 | end 281 | 282 | def caption 283 | @caption ||= @raw_level.getCaption 284 | end 285 | 286 | def depth 287 | @raw_level.getDepth 288 | end 289 | 290 | def cardinality 291 | @cardinality = @raw_level.getCardinality 292 | end 293 | 294 | def cardinality=(value) 295 | mondrian_level.setApproxRowCount(value || Java::JavaLang::Integer::MIN_VALUE) 296 | end 297 | 298 | def members_count 299 | @members_count ||= begin 300 | if cardinality >= 0 301 | cardinality 302 | else 303 | Error.wrap_native_exception do 304 | @raw_level.getMembers.size 305 | end 306 | end 307 | end 308 | end 309 | 310 | def members 311 | Error.wrap_native_exception do 312 | @raw_level.getMembers.map{|m| Member.new(m)} 313 | end 314 | end 315 | 316 | def mondrian_level 317 | @raw_level.unwrap(Java::MondrianOlap::Level.java_class) 318 | end 319 | 320 | include Annotated 321 | def annotations 322 | annotations_for(@raw_level) 323 | end 324 | 325 | def visible? 326 | @raw_level.isVisible 327 | end 328 | 329 | end 330 | 331 | class Member 332 | def initialize(raw_member) 333 | @raw_member = raw_member 334 | end 335 | 336 | attr_reader :raw_member 337 | 338 | def name 339 | @name ||= @raw_member.getName 340 | end 341 | 342 | def full_name 343 | @full_name ||= @raw_member.getUniqueName 344 | end 345 | 346 | def caption 347 | @caption ||= @raw_member.getCaption 348 | end 349 | 350 | def calculated? 351 | @raw_member.isCalculated 352 | end 353 | 354 | def calculated_in_query? 355 | @raw_member.isCalculatedInQuery 356 | end 357 | 358 | def visible? 359 | @raw_member.isVisible 360 | end 361 | 362 | def all_member? 363 | @raw_member.isAll 364 | end 365 | 366 | def drillable? 367 | return false if calculated? 368 | # @raw_member.getChildMemberCount > 0 369 | # This hopefully is faster than counting actual child members 370 | raw_level = @raw_member.getLevel 371 | raw_levels = raw_level.getHierarchy.getLevels 372 | raw_levels.indexOf(raw_level) < raw_levels.size - 1 373 | end 374 | 375 | def depth 376 | @raw_member.getDepth 377 | end 378 | 379 | def dimension_type 380 | case @raw_member.getDimension.getDimensionType 381 | when Java::OrgOlap4jMetadata::Dimension::Type::TIME 382 | :time 383 | when Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 384 | :measures 385 | else 386 | :standard 387 | end 388 | end 389 | 390 | def children 391 | Error.wrap_native_exception do 392 | @raw_member.getChildMembers.map{|m| Member.new(m)} 393 | end 394 | end 395 | 396 | def descendants_at_level(level) 397 | Error.wrap_native_exception do 398 | raw_level = @raw_member.getLevel 399 | raw_levels = raw_level.getHierarchy.getLevels 400 | current_level_index = raw_levels.indexOf(raw_level) 401 | descendants_level_index = raw_levels.indexOfName(level) 402 | 403 | return nil unless descendants_level_index > current_level_index 404 | 405 | members = [self] 406 | (descendants_level_index - current_level_index).times do 407 | members = members.map do |member| 408 | member.children 409 | end.flatten 410 | end 411 | members 412 | end 413 | end 414 | 415 | def property_value(name) 416 | if property = @raw_member.getProperties.get(name) 417 | @raw_member.getPropertyValue(property) 418 | end 419 | end 420 | 421 | def property_formatted_value(name) 422 | if property = @raw_member.getProperties.get(name) 423 | @raw_member.getPropertyFormattedValue(property) 424 | end 425 | end 426 | 427 | def mondrian_member 428 | @raw_member.unwrap(Java::MondrianOlap::Member.java_class) 429 | end 430 | 431 | include Annotated 432 | def annotations 433 | annotations_for(@raw_member) 434 | end 435 | 436 | def format_string 437 | format_exp = property_value('FORMAT_EXP') 438 | if format_exp && format_exp =~ /\A"(.*)"\z/ 439 | format_exp = $1 440 | end 441 | if format_exp && !format_exp.empty? 442 | format_exp 443 | end 444 | end 445 | 446 | def cell_formatter_name 447 | if cf = cell_formatter 448 | cf.class.name.split('::').last.gsub(/Udf\z/, '') 449 | end 450 | end 451 | 452 | def cell_formatter 453 | if dimension_type == :measures 454 | cube_measure = raw_member.unwrap(Java::MondrianOlap::Member.java_class) 455 | if value_formatter = cube_measure.getFormatter 456 | f = value_formatter.java_class.declared_field('cf') 457 | f.accessible = true 458 | f.value(value_formatter) 459 | end 460 | end 461 | end 462 | end 463 | 464 | class CacheControl 465 | def initialize(connection, cube) 466 | @connection = connection 467 | @cube = cube 468 | @mondrian_cube = @cube.raw_cube.unwrap(Java::MondrianOlap::Cube.java_class) 469 | @cache_control = @connection.raw_cache_control 470 | end 471 | 472 | def flush_region_cache_with_segments(*segment_names) 473 | members = segment_names.map { |name| @cube.member_by_segments(*name).mondrian_member } 474 | flush(members) 475 | end 476 | 477 | def flush_region_cache_with_full_names(*full_names) 478 | members = full_names.map { |name| @cube.member(*name).mondrian_member } 479 | flush(members) 480 | end 481 | 482 | private 483 | 484 | def flush(members) 485 | regions = members.map do |member| 486 | @cache_control.create_member_region(member, true) 487 | end 488 | regions << @cache_control.create_measures_region(@mondrian_cube) 489 | @cache_control.flush(@cache_control.create_crossjoin_region(*regions)) 490 | end 491 | end 492 | end 493 | end 494 | -------------------------------------------------------------------------------- /lib/mondrian/olap/error.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | 4 | NATIVE_ERROR_REGEXP = /^(org\.olap4j\.|mondrian\.|java\.lang\.reflect\.UndeclaredThrowableException\: Mondrian Error\:)/ 5 | 6 | class Error < StandardError 7 | # root_cause will be nil if there is no cause for wrapped native error 8 | # root_cause_message will have either root_cause message or wrapped native error message 9 | attr_reader :native_error, :root_cause_message, :root_cause, :profiling_handler 10 | 11 | def initialize(native_error, options = {}) 12 | @native_error = native_error 13 | get_root_cause 14 | super(native_error.toString) 15 | add_root_cause_to_backtrace 16 | get_profiling(options) 17 | end 18 | 19 | def self.wrap_native_exception(options = {}) 20 | yield 21 | # TokenMgrError for some unknown reason extends java.lang.Error which normally should not be rescued 22 | rescue Java::JavaLang::Exception, Java::MondrianParser::TokenMgrError => e 23 | if e.toString =~ NATIVE_ERROR_REGEXP 24 | raise Mondrian::OLAP::Error.new(e, options) 25 | else 26 | raise 27 | end 28 | end 29 | 30 | def profiling_plan 31 | if profiling_handler && (plan = profiling_handler.plan) 32 | plan.gsub("\r\n", "\n") 33 | end 34 | end 35 | 36 | def profiling_timing 37 | profiling_handler.timing if profiling_handler 38 | end 39 | 40 | def profiling_timing_string 41 | if profiling_timing && (timing_string = profiling_timing.toString) 42 | timing_string.gsub("\r\n", "\n").sub(Mondrian::OLAP::Result::QUERY_TIMING_CUMULATIVE_REGEXP, '') 43 | end 44 | end 45 | 46 | private 47 | 48 | def get_root_cause 49 | @root_cause = nil 50 | e = @native_error 51 | while e.respond_to?(:cause) && (cause = e.cause) 52 | @root_cause = e = cause 53 | end 54 | message = e.message 55 | if message =~ /\AMondrian Error:(.*)\Z/m 56 | message = $1 57 | end 58 | @root_cause_message = message 59 | end 60 | 61 | def add_root_cause_to_backtrace 62 | bt = @native_error.backtrace 63 | if @root_cause 64 | root_cause_bt = Array(@root_cause.backtrace) 65 | root_cause_bt[0, 10].reverse.each do |bt_line| 66 | bt.unshift "root cause: #{bt_line}" 67 | end 68 | bt.unshift "root cause: #{@root_cause.java_class.name}: #{@root_cause.message.chomp}" 69 | end 70 | set_backtrace bt 71 | end 72 | 73 | def get_profiling(options) 74 | if statement = options[:profiling_statement] 75 | f = Java::mondrian.olap4j.MondrianOlap4jStatement.java_class.declared_field("openCellSet") 76 | f.accessible = true 77 | if cell_set = f.value(statement) 78 | cell_set.close 79 | # Starting from Mondrian 9.2 query plan was not available in case of error, need to get it explicitly. 80 | if (@profiling_handler = statement.getProfileHandler) && !@profiling_handler.timing 81 | query = statement.getQuery 82 | string_writer = Java::java.io.StringWriter.new 83 | print_writer = Java::java.io.PrintWriter.new(string_writer) 84 | query.explain(print_writer) 85 | print_writer.close 86 | @profiling_handler.explain(string_writer.toString, cell_set.getQueryTiming) 87 | end 88 | end 89 | end 90 | end 91 | 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/mondrian/olap/query.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | class Query 4 | def self.from(connection, cube_name) 5 | query = self.new(connection) 6 | query.cube_name = cube_name 7 | query 8 | end 9 | 10 | attr_accessor :cube_name 11 | 12 | def initialize(connection) 13 | @connection = connection 14 | @cube = nil 15 | @axes = [] 16 | @where = [] 17 | @with = [] 18 | end 19 | 20 | # Add new axis(i) to query 21 | # or return array of axis(i) members if no arguments specified 22 | def axis(i, *axis_members) 23 | if axis_members.empty? 24 | @axes[i] 25 | else 26 | @axes[i] ||= [] 27 | @current_set = @axes[i] 28 | if axis_members.length == 1 && axis_members[0].is_a?(Array) 29 | @current_set.concat(axis_members[0]) 30 | else 31 | @current_set.concat(axis_members) 32 | end 33 | self 34 | end 35 | end 36 | 37 | AXIS_ALIASES = %w(columns rows pages chapters sections) 38 | AXIS_ALIASES.each_with_index do |axis, i| 39 | class_eval <<~RUBY, __FILE__, __LINE__ + 1 40 | def #{axis}(*axis_members) 41 | axis(#{i}, *axis_members) 42 | end 43 | RUBY 44 | end 45 | 46 | %w(crossjoin nonempty_crossjoin).each do |method| 47 | class_eval <<~RUBY, __FILE__, __LINE__ + 1 48 | def #{method}(*axis_members) 49 | validate_current_set 50 | raise ArgumentError, "specify set of members for #{method} method" if axis_members.empty? 51 | members = axis_members.length == 1 && axis_members[0].is_a?(Array) ? axis_members[0] : axis_members 52 | add_current_set_function :#{method}, members 53 | self 54 | end 55 | RUBY 56 | end 57 | 58 | def except(*axis_members) 59 | validate_current_set 60 | raise ArgumentError, "specify set of members for except method" if axis_members.empty? 61 | members = axis_members.length == 1 && axis_members[0].is_a?(Array) ? axis_members[0] : axis_members 62 | add_last_set_function :except, members 63 | self 64 | end 65 | 66 | def nonempty 67 | validate_current_set 68 | add_current_set_function :nonempty 69 | self 70 | end 71 | 72 | def distinct 73 | validate_current_set 74 | add_current_set_function :distinct 75 | self 76 | end 77 | 78 | def filter(condition, options = {}) 79 | validate_current_set 80 | add_current_set_function :filter, condition, options[:as] 81 | self 82 | end 83 | 84 | def filter_last(condition, options = {}) 85 | validate_current_set 86 | add_last_set_function :filter, condition, options[:as] 87 | self 88 | end 89 | 90 | def filter_nonempty 91 | validate_current_set 92 | filter('NOT ISEMPTY(S.CURRENT)', as: 'S') 93 | end 94 | 95 | def generate(*axis_members) 96 | validate_current_set 97 | all = if axis_members.last == :all 98 | axis_members.pop 99 | 'ALL' 100 | end 101 | raise ArgumentError, "specify set of members for generate method" if axis_members.empty? 102 | members = axis_members.length == 1 && axis_members[0].is_a?(Array) ? axis_members[0] : axis_members 103 | add_current_set_function :generate, members, all 104 | self 105 | end 106 | 107 | VALID_ORDERS = ['ASC', 'BASC', 'DESC', 'BDESC'] 108 | 109 | def order(expression, direction) 110 | validate_current_set 111 | direction = direction.to_s.upcase 112 | raise ArgumentError, "invalid order direction #{direction.inspect}," \ 113 | " should be one of #{VALID_ORDERS.inspect[1..-2]}" unless VALID_ORDERS.include?(direction) 114 | add_current_set_function :order, expression, direction 115 | self 116 | end 117 | 118 | %w(top bottom).each do |extreme| 119 | class_eval <<~RUBY, __FILE__, __LINE__ + 1 120 | def #{extreme}_count(count, expression = nil) 121 | validate_current_set 122 | add_current_set_function :#{extreme}_count, count, expression 123 | self 124 | end 125 | RUBY 126 | 127 | %w(percent sum).each do |extreme_name| 128 | class_eval <<~RUBY, __FILE__, __LINE__ + 1 129 | def #{extreme}_#{extreme_name}(value, expression) 130 | validate_current_set 131 | add_current_set_function :#{extreme}_#{extreme_name}, value, expression 132 | self 133 | end 134 | RUBY 135 | end 136 | end 137 | 138 | def hierarchize(order = nil, all = nil) 139 | validate_current_set 140 | order = order && order.to_s.upcase 141 | raise ArgumentError, "invalid hierarchize order #{order.inspect}" unless order.nil? || order == 'POST' 142 | if all.nil? 143 | add_last_set_function :hierarchize, order 144 | else 145 | add_current_set_function :hierarchize, order 146 | end 147 | self 148 | end 149 | 150 | def hierarchize_all(order = nil) 151 | validate_current_set 152 | hierarchize(order, :all) 153 | end 154 | 155 | # Add new WHERE condition to query 156 | # or return array of existing conditions if no arguments specified 157 | def where(*members) 158 | if members.empty? 159 | @where 160 | else 161 | @current_set = @where 162 | if members.length == 1 && members[0].is_a?(Array) 163 | @where.concat(members[0]) 164 | else 165 | @where.concat(members) 166 | end 167 | self 168 | end 169 | end 170 | 171 | # Add definition of calculated member 172 | def with_member(member_name) 173 | @with << [:member, member_name] 174 | @current_set = nil 175 | self 176 | end 177 | 178 | # Add definition of named_set 179 | def with_set(set_name) 180 | @current_set = [] 181 | @with << [:set, set_name, @current_set] 182 | self 183 | end 184 | 185 | # return array of member and set definitions 186 | def with 187 | @with 188 | end 189 | 190 | # Add definition to calculated member or to named set 191 | def as(*params) 192 | # definition of named set 193 | if @current_set 194 | if params.empty? 195 | raise ArgumentError, "named set cannot be empty" 196 | else 197 | raise ArgumentError, "cannot use 'as' method before with_set method" unless @current_set.empty? 198 | if params.length == 1 && params[0].is_a?(Array) 199 | @current_set.concat(params[0]) 200 | else 201 | @current_set.concat(params) 202 | end 203 | end 204 | # definition of calculated member 205 | else 206 | member_definition = @with.last 207 | if params.last.is_a?(Hash) 208 | options = params.pop 209 | # if formatter does not include . then it should be ruby formatter name 210 | if (formatter = options[:cell_formatter]) && !formatter.include?('.') 211 | options = options.merge(:cell_formatter => Mondrian::OLAP::Schema::CellFormatter.new(formatter).class_name) 212 | end 213 | else 214 | options = nil 215 | end 216 | raise ArgumentError, "cannot use 'as' method before with_member method" unless member_definition && 217 | member_definition[0] == :member && member_definition.length == 2 218 | raise ArgumentError, "calculated member definition should be single expression" unless params.length == 1 219 | member_definition << params[0] 220 | member_definition << options if options 221 | end 222 | self 223 | end 224 | 225 | def to_mdx 226 | mdx = "" 227 | mdx << "WITH #{with_to_mdx}\n" unless @with.empty? 228 | mdx << "SELECT #{axis_to_mdx}\n" 229 | mdx << "FROM #{from_to_mdx}" 230 | mdx << "\nWHERE #{where_to_mdx}" unless @where.empty? 231 | mdx 232 | end 233 | 234 | def execute(parameters = {}) 235 | @connection.execute to_mdx, parameters 236 | end 237 | 238 | def execute_drill_through(options = {}) 239 | drill_through_mdx = "DRILLTHROUGH " 240 | drill_through_mdx << "MAXROWS #{options[:max_rows]} " if options[:max_rows] 241 | drill_through_mdx << to_mdx 242 | drill_through_mdx << " RETURN #{Array(options[:return]).join(',')}" if options[:return] 243 | @connection.execute_drill_through drill_through_mdx 244 | end 245 | 246 | private 247 | 248 | def validate_current_set 249 | unless @current_set 250 | method_name = caller_locations(1,1).first&.label 251 | raise ArgumentError, "cannot use #{method_name} method before axis or with_set method" 252 | end 253 | end 254 | 255 | def add_current_set_function(function_name, *args) 256 | remove_last_nil_arg(args) 257 | @current_set.replace [function_name, @current_set.clone, *args] 258 | end 259 | 260 | def add_last_set_function(function_name, *args) 261 | remove_last_nil_arg(args) 262 | if current_set_crossjoin? 263 | @current_set[2] = [function_name, @current_set[2], *args] 264 | else 265 | add_current_set_function function_name, *args 266 | end 267 | end 268 | 269 | def remove_last_nil_arg(args) 270 | args.pop if args.length > 0 && args.last.nil? 271 | end 272 | 273 | CROSSJOIN_FUNCTIONS = [:crossjoin, :nonempty_crossjoin].freeze 274 | 275 | def current_set_crossjoin? 276 | CROSSJOIN_FUNCTIONS.include?(@current_set&.first) 277 | end 278 | 279 | def with_to_mdx 280 | @with.map do |definition| 281 | case definition[0] 282 | when :member 283 | member_name = definition[1] 284 | expression = definition[2] 285 | options = definition[3] 286 | options_string = '' 287 | options && options.each do |option, value| 288 | option_name = case option 289 | when :caption 290 | '$caption' 291 | else 292 | option.to_s.upcase 293 | end 294 | options_string << ", #{option_name} = #{quote_value(value)}" 295 | end 296 | "MEMBER #{member_name} AS #{quote_value(expression)}#{options_string}" 297 | when :set 298 | set_name = definition[1] 299 | set_members = definition[2] 300 | "SET #{set_name} AS #{quote_value(members_to_mdx(set_members))}" 301 | end 302 | end.join("\n") 303 | end 304 | 305 | def axis_to_mdx 306 | mdx = "" 307 | @axes.each_with_index do |axis_members, i| 308 | axis_name = AXIS_ALIASES[i] ? AXIS_ALIASES[i].upcase : "AXIS(#{i})" 309 | mdx << ",\n" if i > 0 310 | mdx << members_to_mdx(axis_members) << " ON " << axis_name 311 | end 312 | mdx 313 | end 314 | 315 | MDX_FUNCTIONS = { 316 | top_count: 'TOPCOUNT', 317 | top_percent: 'TOPPERCENT', 318 | top_sum: 'TOPSUM', 319 | bottom_count: 'BOTTOMCOUNT', 320 | bottom_percent: 'BOTTOMPERCENT', 321 | bottom_sum: 'BOTTOMSUM' 322 | }.freeze 323 | 324 | def members_to_mdx(members) 325 | members ||= [] 326 | # If only one member which does not end with ] or .Item(...) or Default...Member 327 | # then assume it is expression which returns set. 328 | if members.length == 1 && members[0] !~ /(\]|\.Item\(\d+\)|\.Default\w*Member)\z/i 329 | members[0] 330 | elsif members[0].is_a?(Symbol) 331 | case members[0] 332 | when :crossjoin 333 | "CROSSJOIN(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 334 | when :nonempty_crossjoin 335 | "NONEMPTYCROSSJOIN(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 336 | when :except 337 | "EXCEPT(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 338 | when :nonempty 339 | "NON EMPTY #{members_to_mdx(members[1])}" 340 | when :distinct 341 | "DISTINCT(#{members_to_mdx(members[1])})" 342 | when :filter 343 | as_alias = members[3] ? " AS #{members[3]}" : nil 344 | "FILTER(#{members_to_mdx(members[1])}#{as_alias}, #{members[2]})" 345 | when :generate 346 | "GENERATE(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])}#{members[3] && ", #{members[3]}"})" 347 | when :order 348 | "ORDER(#{members_to_mdx(members[1])}, #{expression_to_mdx(members[2])}, #{members[3]})" 349 | when :top_count, :bottom_count 350 | mdx = "#{MDX_FUNCTIONS[members[0]]}(#{members_to_mdx(members[1])}, #{members[2]}" 351 | mdx << (members[3] ? ", #{expression_to_mdx(members[3])})" : ")") 352 | when :top_percent, :top_sum, :bottom_percent, :bottom_sum 353 | "#{MDX_FUNCTIONS[members[0]]}(#{members_to_mdx(members[1])}, #{members[2]}, #{expression_to_mdx(members[3])})" 354 | when :hierarchize 355 | "HIERARCHIZE(#{members_to_mdx(members[1])}#{members[2] && ", #{members[2]}"})" 356 | else 357 | raise ArgumentError, "Cannot generate MDX for invalid set operation #{members[0].inspect}" 358 | end 359 | else 360 | "{#{members.join(', ')}}" 361 | end 362 | end 363 | 364 | def expression_to_mdx(expression) 365 | expression.is_a?(Array) ? "(#{expression.join(', ')})" : expression 366 | end 367 | 368 | def from_to_mdx 369 | "[#{@cube_name}]" 370 | end 371 | 372 | def where_to_mdx 373 | # generate set MDX expression 374 | if @where[0].is_a?(Symbol) || 375 | @where.length > 1 && @where.map{|full_name| extract_dimension_name(full_name)}.uniq.length == 1 376 | members_to_mdx(@where) 377 | # generate tuple MDX expression 378 | else 379 | where_to_mdx_tuple 380 | end 381 | end 382 | 383 | def where_to_mdx_tuple 384 | mdx = '(' 385 | mdx << @where.map do |condition| 386 | condition 387 | end.join(', ') 388 | mdx << ')' 389 | end 390 | 391 | def quote_value(value) 392 | case value 393 | when String 394 | "'#{value.gsub("'", "''")}'" 395 | when TrueClass, FalseClass 396 | value ? 'TRUE' : 'FALSE' 397 | when NilClass 398 | 'NULL' 399 | else 400 | "#{value}" 401 | end 402 | end 403 | 404 | def extract_dimension_name(full_name) 405 | # "[Foo [Bar]]].[Baz]" => "Foo [Bar]" 406 | if full_name 407 | full_name.gsub(/\A\[|\]\z/, '').split('].[').first.try(:gsub, ']]', ']') 408 | end 409 | end 410 | end 411 | end 412 | end 413 | -------------------------------------------------------------------------------- /lib/mondrian/olap/schema_element.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | class SchemaElement 4 | def initialize(name = nil, attributes = {}, parent = nil, &block) 5 | # if just attributes hash provided 6 | if name.is_a?(Hash) && attributes == {} 7 | attributes = name 8 | name = nil 9 | end 10 | @attributes = {} 11 | if name 12 | if self.class.content 13 | if attributes.is_a?(Hash) 14 | @content = name 15 | else 16 | # used for Annotation element where both name and content is given as arguments 17 | @attributes[:name] = name 18 | @content = attributes 19 | attributes = {} 20 | end 21 | else 22 | @attributes[:name] = name 23 | end 24 | end 25 | @attributes.merge!(attributes) 26 | self.class.elements.each do |element| 27 | instance_variable_set("@#{pluralize(element)}", []) 28 | end 29 | # extract annotations from options 30 | if @attributes[:annotations] && self.class.elements.include?(:annotations) 31 | annotations @attributes.delete(:annotations) 32 | end 33 | @xml_fragments = [] 34 | instance_eval(&block) if block 35 | end 36 | 37 | def self.attributes(*names) 38 | names.each do |name| 39 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 40 | def #{name}(*args) 41 | if args.empty? 42 | @attributes[:#{name}] 43 | elsif args.size == 1 44 | @attributes[:#{name}] = args[0] 45 | else 46 | raise ArgumentError, "too many arguments" 47 | end 48 | end 49 | RUBY 50 | end 51 | end 52 | 53 | def self.data_dictionary_names(*names) 54 | return @data_dictionary_names || [] if names.empty? 55 | @data_dictionary_names ||= [] 56 | @data_dictionary_names.concat(names) 57 | end 58 | 59 | def self.elements(*names) 60 | return @elements || [] if names.empty? 61 | 62 | @elements ||= [] 63 | @elements.concat(names) 64 | 65 | names.each do |name| 66 | next if name == :xml 67 | attr_reader pluralize(name).to_sym 68 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 69 | def #{name}(name=nil, attributes = {}, &block) 70 | new_element = Schema::#{camel_case(name)}.new(name, attributes, self, &block) 71 | @#{pluralize(name)} << new_element 72 | new_element 73 | end 74 | RUBY 75 | if name == :annotations 76 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 77 | def annotations_hash 78 | hash = {} 79 | @annotationss.each do |annotations| 80 | annotations.annotations.each do |annotation| 81 | hash[annotation.name] = annotation.content 82 | end 83 | end 84 | hash 85 | end 86 | RUBY 87 | end 88 | end 89 | end 90 | 91 | def self.content(type = nil) 92 | return @content if type.nil? 93 | attr_reader :content 94 | @content = type 95 | end 96 | 97 | attr_reader :xml_fragments 98 | def xml(string) 99 | string = string.strip 100 | fragment = Nokogiri::XML::DocumentFragment.parse(string) 101 | raise ArgumentError, "Invalid XML fragment:\n#{string}" if fragment.children.empty? 102 | @xml_fragments << string 103 | end 104 | 105 | def to_xml(options = {}) 106 | options[:upcase_data_dictionary] = @upcase_data_dictionary unless @upcase_data_dictionary.nil? 107 | Nokogiri::XML::Builder.new(:encoding => 'UTF-8') do |xml| 108 | add_to_xml(xml, options) 109 | end.to_xml 110 | end 111 | 112 | protected 113 | 114 | def add_to_xml(xml, options) 115 | if self.class.content 116 | xml.send(tag_name(self.class.name), @content, xmlized_attributes(options)) 117 | else 118 | xml.send(tag_name(self.class.name), xmlized_attributes(options)) do 119 | xml_fragments_added = false 120 | self.class.elements.each do |element| 121 | if element == :xml 122 | add_xml_fragments(xml) 123 | xml_fragments_added = true 124 | else 125 | instance_variable_get("@#{pluralize(element)}").each {|item| item.add_to_xml(xml, options)} 126 | end 127 | end 128 | add_xml_fragments(xml) unless xml_fragments_added 129 | end 130 | end 131 | end 132 | 133 | def add_xml_fragments(xml) 134 | @xml_fragments.each do |xml_fragment| 135 | xml.send(:insert, Nokogiri::XML::DocumentFragment.parse(xml_fragment)) 136 | end 137 | end 138 | 139 | private 140 | 141 | def xmlized_attributes(options) 142 | # data dictionary values should be in uppercase if schema defined with :upcase_data_dictionary => true 143 | # or by default when using Oracle or Snowflake driver (can be overridden by :upcase_data_dictionary => false) 144 | upcase_attributes = if options[:upcase_data_dictionary].nil? && %w(oracle snowflake).include?(options[:driver]) || 145 | options[:upcase_data_dictionary] 146 | self.class.data_dictionary_names 147 | else 148 | [] 149 | end 150 | hash = {} 151 | @attributes.each do |attr, value| 152 | # Support value calculation in parallel threads. 153 | # value could be either Thread or a future object from concurrent-ruby 154 | value = value.value if value.respond_to?(:value) 155 | value = value.upcase if upcase_attributes.include?(attr) && value.is_a?(String) 156 | hash[ 157 | # camelcase attribute name 158 | attr.to_s.gsub(/_([^_]+)/){|m| $1.capitalize} 159 | ] = value 160 | end 161 | hash 162 | end 163 | 164 | def self.pluralize(string) 165 | string = string.to_s 166 | case string 167 | when /^(.*)y$/ 168 | "#{$1}ies" 169 | else 170 | "#{string}s" 171 | end 172 | end 173 | 174 | def pluralize(string) 175 | self.class.pluralize(string) 176 | end 177 | 178 | def self.camel_case(string) 179 | string.to_s.split('_').map{|s| s.capitalize}.join('') 180 | end 181 | 182 | def camel_case(string) 183 | self.class.camel_case(string) 184 | end 185 | 186 | def tag_name(string) 187 | string.split('::').last << '_' 188 | end 189 | end 190 | 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/mondrian/olap/schema_udf.rb: -------------------------------------------------------------------------------- 1 | require 'jruby/core_ext' 2 | 3 | module Mondrian 4 | module OLAP 5 | class Schema < SchemaElement 6 | 7 | def user_defined_cell_formatter(name, &block) 8 | CellFormatter.new(name, &block) 9 | end 10 | 11 | module ScriptElements 12 | private 13 | 14 | def ruby(*options, &block) 15 | udf_class_name = if options.include?(:shared) 16 | "#{name.capitalize}Udf" 17 | end 18 | if udf_class_name && self.class.const_defined?(udf_class_name) 19 | udf_class = self.class.const_get(udf_class_name) 20 | else 21 | udf_class = Class.new(RubyUdfBase) 22 | self.class.const_set(udf_class_name, udf_class) if udf_class_name 23 | end 24 | udf_class.function_name = name 25 | udf_class.class_eval(&block) 26 | udf_java_class = udf_class.become_java!(false) 27 | 28 | class_name udf_java_class.getName 29 | end 30 | 31 | def ruby_formatter(options, interface_class, method, signature, &block) 32 | formatter_class_name = if options.include?(:shared) && @attributes[:name] 33 | ruby_formatter_name_to_class_name(@attributes[:name]) 34 | end 35 | if formatter_class_name && self.class.const_defined?(formatter_class_name) 36 | formatter_class = self.class.const_get(formatter_class_name) 37 | else 38 | formatter_class = Class.new 39 | self.class.const_set(formatter_class_name, formatter_class) if formatter_class_name 40 | end 41 | 42 | formatter_class.class_eval do 43 | include interface_class 44 | define_method method, &block 45 | add_method_signature(method, signature) 46 | end 47 | formatter_java_class = formatter_class.become_java!(false) 48 | class_name formatter_java_class.getName 49 | end 50 | 51 | def ruby_formatter_name_to_class_name(name) 52 | # upcase just first character 53 | "#{name.sub(/\A./){|m| m.upcase}}Udf" 54 | end 55 | 56 | def ruby_formatter_java_class_name(name) 57 | "rubyobj.#{self.class.name.gsub('::', '.')}.#{ruby_formatter_name_to_class_name(name)}" 58 | end 59 | 60 | end 61 | 62 | class UserDefinedFunction < SchemaElement 63 | include ScriptElements 64 | 65 | attributes :name, # Name with which the user-defined function will be referenced in MDX expressions. 66 | # Name of the class which implements this user-defined function. 67 | # Must implement the mondrian.spi.UserDefinedFunction interface. 68 | :class_name 69 | elements :script 70 | 71 | class RubyUdfBase 72 | include Java::mondrian.spi.UserDefinedFunction 73 | def self.function_name=(name); @function_name = name; end 74 | def self.function_name; @function_name; end 75 | 76 | def getName 77 | self.class.function_name 78 | end 79 | add_method_signature("getName", [java.lang.String]) 80 | 81 | def getDescription 82 | getName 83 | end 84 | add_method_signature("getDescription", [java.lang.String]) 85 | 86 | def self.parameters(*types) 87 | if types.empty? 88 | @parameters || [] 89 | else 90 | @parameters = types.map{|type| stringified_type(type)} 91 | end 92 | end 93 | 94 | def self.returns(type = nil) 95 | if type 96 | @returns = stringified_type(type) 97 | else 98 | @returns || 'Scalar' 99 | end 100 | end 101 | 102 | VALID_SYNTAX_TYPES = %w(Function Property Method) 103 | def self.syntax(type = nil) 104 | if type 105 | type = stringify(type) 106 | raise ArgumentError, "invalid user defined function type #{type.inspect}" unless VALID_SYNTAX_TYPES.include? type 107 | @syntax = type 108 | else 109 | @syntax || 'Function' 110 | end 111 | end 112 | 113 | def getSyntax 114 | Java::mondrian.olap.Syntax.const_get self.class.syntax 115 | end 116 | add_method_signature("getSyntax", [Java::mondrian.olap.Syntax]) 117 | 118 | UDF_SCALAR_TYPES = { 119 | 'Numeric' => Java::mondrian.olap.type.NumericType, 120 | 'String' => Java::mondrian.olap.type.StringType, 121 | 'Boolean' => Java::mondrian.olap.type.BooleanType, 122 | 'DateTime' => Java::mondrian.olap.type.DateTimeType, 123 | 'Decimal' => Java::mondrian.olap.type.DecimalType, 124 | 'Scalar' => Java::mondrian.olap.type.ScalarType 125 | } 126 | UDF_OTHER_TYPES = { 127 | 'Member' => Java::mondrian.olap.type.MemberType::Unknown, 128 | 'Tuple' => Java::mondrian.olap.type.TupleType.new([].to_java(Java::mondrian.olap.type.Type)), 129 | 'Hierarchy' => Java::mondrian.olap.type.HierarchyType.new(nil, nil), 130 | 'Level' => Java::mondrian.olap.type.LevelType::Unknown 131 | } 132 | UDF_OTHER_TYPES['Set'] = UDF_OTHER_TYPES['MemberSet'] = Java::mondrian.olap.type.SetType.new(UDF_OTHER_TYPES['Member']) 133 | UDF_OTHER_TYPES['TupleSet'] = Java::mondrian.olap.type.SetType.new(UDF_OTHER_TYPES['Tuple']) 134 | 135 | def getParameterTypes 136 | @parameterTypes ||= self.class.parameters.map{|p| get_java_type(p)} 137 | end 138 | class_loader = JRuby.runtime.jruby_class_loader 139 | type_array_class = java.lang.Class.forName "[Lmondrian.olap.type.Type;", true, class_loader 140 | add_method_signature("getParameterTypes", [type_array_class]) 141 | 142 | def getReturnType(parameterTypes) 143 | @returnType ||= get_java_type self.class.returns 144 | end 145 | add_method_signature("getReturnType", [Java::mondrian.olap.type.Type, type_array_class]) 146 | 147 | def getReservedWords 148 | nil 149 | end 150 | string_array_class = java.lang.Class.forName "[Ljava.lang.String;", true, class_loader 151 | add_method_signature("getReservedWords", [string_array_class]) 152 | 153 | def execute(evaluator, arguments) 154 | values = [] 155 | self.class.parameters.each_with_index do |p, i| 156 | value = UDF_SCALAR_TYPES[p] ? arguments[i].evaluateScalar(evaluator) : arguments[i].evaluate(evaluator) 157 | values << value 158 | end 159 | call_with_evaluator(evaluator, *values) 160 | end 161 | arguments_array_class = java.lang.Class.forName "[Lmondrian.spi.UserDefinedFunction$Argument;", true, class_loader 162 | add_method_signature("execute", [java.lang.Object, Java::mondrian.olap.Evaluator, arguments_array_class]) 163 | 164 | # Override this method if evaluator is needed 165 | def call_with_evaluator(evaluator, *values) 166 | call(*values) 167 | end 168 | 169 | private 170 | 171 | def get_java_type(type) 172 | if type_class = UDF_SCALAR_TYPES[type] 173 | type_class.new 174 | else 175 | UDF_OTHER_TYPES[type] 176 | end 177 | end 178 | 179 | def self.stringified_type(type) 180 | type_as_string = stringify(type) 181 | if UDF_SCALAR_TYPES[type_as_string] || UDF_OTHER_TYPES[type_as_string] 182 | type_as_string 183 | else 184 | raise ArgumentError, "Invalid user defined function type #{type.inspect}" 185 | end 186 | end 187 | 188 | def self.stringify(arg) 189 | arg = arg.to_s.split('_').map{|s| s.capitalize}.join if arg.is_a? Symbol 190 | arg 191 | end 192 | end 193 | 194 | def ruby(*options, &block) 195 | udf_class_name = if options.include?(:shared) 196 | "#{name.capitalize}Udf" 197 | end 198 | if udf_class_name && self.class.const_defined?(udf_class_name) 199 | udf_class = self.class.const_get(udf_class_name) 200 | else 201 | udf_class = Class.new(RubyUdfBase) 202 | self.class.const_set(udf_class_name, udf_class) if udf_class_name 203 | end 204 | udf_class.function_name = name 205 | udf_class.class_eval(&block) 206 | udf_java_class = udf_class.become_java!(false) 207 | 208 | class_name udf_java_class.getName 209 | end 210 | end 211 | 212 | class Script < SchemaElement 213 | attributes :language 214 | content :text 215 | end 216 | 217 | class CellFormatter < SchemaElement 218 | include ScriptElements 219 | # Name of a formatter class for the appropriate cell being displayed. 220 | # The class must implement the mondrian.olap.CellFormatter interface. 221 | attributes :class_name 222 | elements :script 223 | 224 | def initialize(name = nil, attributes = {}, parent = nil, &block) 225 | super 226 | if name && !attributes[:class_name] && !block_given? 227 | # use shared ruby implementation 228 | @attributes[:class_name] = ruby_formatter_java_class_name(name) 229 | @attributes.delete(:name) 230 | end 231 | end 232 | 233 | def ruby(*options, &block) 234 | ruby_formatter(options, Java::mondrian.spi.CellFormatter, 'formatCell', [java.lang.String, java.lang.Object], &block) 235 | end 236 | end 237 | 238 | class MemberFormatter < SchemaElement 239 | include ScriptElements 240 | attributes :class_name 241 | elements :script 242 | 243 | def ruby(*options, &block) 244 | ruby_formatter(options, Java::mondrian.spi.MemberFormatter, 'formatMember', 245 | [java.lang.String, Java::mondrian.olap.Member], &block) 246 | end 247 | end 248 | 249 | class PropertyFormatter < SchemaElement 250 | include ScriptElements 251 | attributes :class_name 252 | elements :script 253 | 254 | def ruby(*options, &block) 255 | ruby_formatter(options, Java::mondrian.spi.PropertyFormatter, 'formatProperty', 256 | [java.lang.String, Java::mondrian.olap.Member, java.lang.String, java.lang.Object], &block) 257 | end 258 | end 259 | 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/mondrian/olap/version.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | VERSION = File.read(File.expand_path('../../../../VERSION', __FILE__)).chomp 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /mondrian-olap.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'mondrian/olap/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "mondrian-olap" 8 | gem.version = ::Mondrian::OLAP::VERSION 9 | gem.authors = ["Raimonds Simanovskis"] 10 | gem.email = ["raimonds.simanovskis@gmail.com"] 11 | gem.description = "JRuby gem for performing multidimensional queries of relational database data using Mondrian OLAP Java library\n" 12 | gem.summary = "JRuby API for Mondrian OLAP Java library" 13 | gem.homepage = "http://github.com/rsim/mondrian-olap" 14 | gem.date = "2023-06-02" 15 | gem.license = 'MIT' 16 | 17 | gem.files = Dir['Changelog.md', 'LICENSE*', 'README.md', 'VERSION', 'lib/**/*', 'spec/**/*'] - 18 | Dir['spec/support/jars/*'] 19 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 20 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 21 | gem.require_paths = ["lib"] 22 | gem.extra_rdoc_files = Dir["README.md"] 23 | 24 | gem.platform = Gem::Platform::RUBY # as otherwise rubygems.org are not showing latest version 25 | gem.add_dependency "nokogiri" 26 | 27 | gem.add_development_dependency "bundler" 28 | gem.add_development_dependency "rake", "~> 13.0.6" 29 | gem.add_development_dependency "rspec", "~> 3.12.0" 30 | gem.add_development_dependency "rdoc", "~> 6.5.0" 31 | gem.add_development_dependency "jdbc-mysql", "~> 8.0.27" 32 | gem.add_development_dependency "jdbc-postgres", "~> 42.2.25" 33 | gem.add_development_dependency "activerecord", "~> 6.1.7.2" 34 | gem.add_development_dependency "activerecord-jdbc-adapter", "~> 61.3" 35 | gem.add_development_dependency "activerecord-oracle_enhanced-adapter", "~> 6.1.6" 36 | gem.add_development_dependency "pry" 37 | gem.add_development_dependency "jar-dependencies", "~> 0.4.1" 38 | end 39 | -------------------------------------------------------------------------------- /spec/connection_role_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Connection role" do 4 | 5 | describe "create connection" do 6 | before(:all) do 7 | @all_roles = [ 8 | @role_name = role_name = 'California manager', 9 | @role_name2 = role_name2 = 'Dummy, with comma', 10 | @simple_role_name = simple_role_name = 'USA manager', 11 | @union_role_name = union_role_name = 'Union California manager', 12 | @intermediate_union_role_name = intermediate_union_role_name = "Intermediate #{union_role_name}" 13 | ] 14 | 15 | @schema = Mondrian::OLAP::Schema.define do 16 | cube 'Sales' do 17 | table 'sales' 18 | dimension 'Gender', :foreign_key => 'customer_id' do 19 | hierarchy :has_all => true, :primary_key => 'id' do 20 | table 'customers' 21 | level 'Gender', :column => 'gender', :unique_members => true, :hide_member_if => 'IfBlankName' 22 | end 23 | end 24 | dimension 'Customers', :foreign_key => 'customer_id' do 25 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id' do 26 | table 'customers' 27 | level 'Country', :column => 'country', :unique_members => true 28 | level 'State Province', :column => 'state_province', :unique_members => true 29 | level 'City', :column => 'city', :unique_members => false 30 | level 'Name', :column => 'fullname', :unique_members => true 31 | end 32 | end 33 | dimension 'Time', :foreign_key => 'time_id' do 34 | hierarchy :has_all => false, :primary_key => 'id' do 35 | table 'time' 36 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true 37 | level 'Quarter', :column => 'quarter', :unique_members => false 38 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false 39 | end 40 | end 41 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum' 42 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 43 | end 44 | role role_name do 45 | schema_grant :access => 'none' do 46 | cube_grant :cube => 'Sales', :access => 'all' do 47 | dimension_grant :dimension => '[Measures]', :access => 'all' 48 | hierarchy_grant :hierarchy => '[Customers]', :access => 'custom', 49 | :top_level => '[Customers].[State Province]', :bottom_level => '[Customers].[City]' do 50 | member_grant :member => '[Customers].[USA].[CA]', :access => 'all' 51 | member_grant :member => '[Customers].[USA].[CA].[Los Angeles]', :access => 'none' 52 | end 53 | end 54 | end 55 | end 56 | role role_name2 57 | 58 | role simple_role_name do 59 | schema_grant :access => 'none' do 60 | cube_grant :cube => 'Sales', :access => 'all' do 61 | hierarchy_grant :hierarchy => '[Customers]', :access => 'custom', :bottom_level => '[Customers].[State Province]' do 62 | member_grant :member => '[Customers].[USA]', :access => 'all' 63 | end 64 | end 65 | end 66 | end 67 | role intermediate_union_role_name do 68 | union do 69 | role_usage role_name: simple_role_name 70 | end 71 | end 72 | role union_role_name do 73 | union do 74 | role_usage role_name: intermediate_union_role_name 75 | end 76 | end 77 | 78 | # to test that Role elements are generated before UserDefinedFunction 79 | user_defined_function 'Factorial' do 80 | ruby do 81 | parameters :numeric 82 | returns :numeric 83 | def call(n) 84 | n <= 1 ? 1 : n * call(n - 1) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | 91 | before(:each) do 92 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 93 | end 94 | 95 | after(:each) do 96 | @olap.role_name = nil if @olap 97 | end 98 | 99 | it "should connect" do 100 | @olap.should be_connected 101 | end 102 | 103 | it "should get available role names" do 104 | @olap.available_role_names.sort.should == @all_roles.sort 105 | end 106 | 107 | it "should not get role name if not set" do 108 | @olap.role_name.should be_nil 109 | @olap.role_names.should be_empty 110 | end 111 | 112 | it "should set and get role name" do 113 | @olap.role_name = @role_name 114 | @olap.role_name.should == @role_name 115 | @olap.role_names.should == [@role_name] 116 | end 117 | 118 | it "should raise error when invalid role name is set" do 119 | expect { 120 | @olap.role_name = 'invalid' 121 | }.to raise_error {|e| 122 | e.should be_kind_of(Mondrian::OLAP::Error) 123 | e.message.should == "org.olap4j.OlapException: Unknown role 'invalid'" 124 | e.root_cause_message.should == "Unknown role 'invalid'" 125 | } 126 | end 127 | 128 | it "should set and get several role names" do 129 | @olap.role_names = [@role_name, @role_name2] 130 | @olap.role_name.should == "[#{@role_name}, #{@role_name2}]" 131 | @olap.role_names.should == [@role_name, @role_name2] 132 | end 133 | 134 | it "should not get non-visible member" do 135 | @cube = @olap.cube('Sales') 136 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should_not be_nil 137 | @olap.role_name = @role_name 138 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 139 | end 140 | 141 | # TODO: investigate why role name is not returned when set in connection string 142 | # it "should set role name from connection parameters" do 143 | # @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema, 144 | # :role => @role_name) 145 | # @olap.role_name.should == @role_name 146 | # end 147 | 148 | it "should not get non-visible member when role name set in connection parameters" do 149 | olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge schema: @schema, role: @role_name) 150 | cube = olap.cube('Sales') 151 | cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 152 | end 153 | 154 | it "should not get non-visible member when several role names set in connection parameters" do 155 | olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge schema: @schema, roles: [@role_name, @role_name2]) 156 | cube = olap.cube('Sales') 157 | cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 158 | end 159 | 160 | it "should see members from ragged dimensions when using single role" do 161 | # Workaround for a Mondrian bug which does not allow access to ragged dimensions when using single role. 162 | # This syntax will create a union role with one role. 163 | @olap.role_names = [@role_name] 164 | cube = @olap.cube('Sales') 165 | cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 166 | cube.member('[Gender].[All Genders]').should_not be_nil 167 | end 168 | 169 | it "should see members from ragged dimensions when using multiple roles" do 170 | @olap.role_names = [@role_name, @role_name2] 171 | cube = @olap.cube('Sales') 172 | cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 173 | cube.member('[Gender].[All Genders]').should_not be_nil 174 | end 175 | 176 | # Test patch for UnionRoleImpl getBottomLevelDepth method 177 | it "should see member as drillable when using union of union role" do 178 | @olap.role_names = [@union_role_name] 179 | cube = @olap.cube('Sales') 180 | cube.member('[Customers].[All Customers]').should be_drillable 181 | cube.member('[Customers].[All Customers].[USA]').should be_drillable 182 | cube.member('[Customers].[All Customers].[USA].[CA]').should_not be_drillable 183 | end 184 | 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Connection" do 4 | 5 | describe "create" do 6 | before(:each) do 7 | @olap = Mondrian::OLAP::Connection.new(CONNECTION_PARAMS_WITH_CATALOG) 8 | end 9 | 10 | it "should not be connected before connection" do 11 | @olap.should_not be_connected 12 | end 13 | 14 | it "should be successful" do 15 | @olap.connect.should == true 16 | end 17 | 18 | end 19 | 20 | describe "create with catalog content" do 21 | before(:all) do 22 | @schema_xml = File.read(CATALOG_FILE) 23 | end 24 | it "should be successful" do 25 | @olap = Mondrian::OLAP::Connection.new(CONNECTION_PARAMS.merge( 26 | :catalog_content => @schema_xml 27 | )) 28 | @olap.connect.should == true 29 | end 30 | 31 | end 32 | 33 | describe "properties" do 34 | before(:all) do 35 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG) 36 | end 37 | 38 | it "should be connected" do 39 | @olap.should be_connected 40 | end 41 | 42 | # to check that correct database dialect is loaded by ServiceDiscovery detected class loader 43 | it "should use corresponding Mondrian dialect" do 44 | # read private "schema" field 45 | schema_field = @olap.raw_schema.getClass.getDeclaredField("schema") 46 | schema_field.setAccessible(true) 47 | private_schema = schema_field.get(@olap.raw_schema) 48 | private_schema.getDialect.java_class.name.should == case MONDRIAN_DRIVER.split('_').last 49 | when 'mysql' then 'mondrian.spi.impl.MySqlDialect' 50 | when 'postgresql' then 'mondrian.spi.impl.PostgreSqlDialect' 51 | when 'oracle' then 'mondrian.spi.impl.OracleDialect' 52 | when 'sqlserver' then 'mondrian.spi.impl.MicrosoftSqlServerDialect' 53 | when 'vertica' then 'mondrian.spi.impl.VerticaDialect' 54 | when 'snowflake' then 'mondrian.spi.impl.SnowflakeDialect' 55 | when 'clickhouse' then 'mondrian.spi.impl.ClickHouseDialect' 56 | when 'mariadb' then 'mondrian.spi.impl.MariaDBDialect' 57 | end 58 | end 59 | 60 | it "should access Mondrian server" do 61 | @olap.mondrian_server.should_not be_nil 62 | end 63 | end 64 | 65 | describe "locale" do 66 | %w(en en_US de de_DE).each do |locale| 67 | it "should set #{locale} locale from connection parameters" do 68 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG.merge(:locale => locale)) 69 | @olap.locale.should == locale 70 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG.merge(:locale => locale.to_sym)) 71 | @olap.locale.should == locale.to_s 72 | end 73 | 74 | it "should set #{locale} locale using setter method" do 75 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG) 76 | @olap.locale = locale 77 | @olap.locale.should == locale 78 | @olap.locale = locale.to_sym 79 | @olap.locale.should == locale.to_s 80 | end 81 | end 82 | end 83 | 84 | describe "close" do 85 | before(:all) do 86 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG) 87 | end 88 | 89 | it "should not be connected after close" do 90 | @olap.close 91 | @olap.should_not be_connected 92 | end 93 | 94 | end 95 | 96 | describe "jdbc_uri" do 97 | before(:all) { @olap_connection = Mondrian::OLAP::Connection } 98 | 99 | describe "SQL Server" do 100 | it "should return a valid JDBC URI" do 101 | @olap_connection.new( 102 | driver: 'sqlserver', 103 | host: 'example.com', 104 | port: 1234, 105 | instance: 'MSSQLSERVER', 106 | database: 'example_db' 107 | ).jdbc_uri.should == 'jdbc:sqlserver://example.com:1234;databaseName=example_db;instanceName=MSSQLSERVER' 108 | end 109 | 110 | it "should return a valid JDBC URI with instance name as property" do 111 | @olap_connection.new( 112 | driver: 'sqlserver', 113 | host: 'example.com', 114 | properties: { 115 | instanceName: "MSSQLSERVER" 116 | } 117 | ).jdbc_uri.should == 'jdbc:sqlserver://example.com;instanceName=MSSQLSERVER' 118 | end 119 | 120 | it "should return a valid JDBC URI with enabled integratedSecurity" do 121 | @olap_connection.new( 122 | driver: 'sqlserver', 123 | host: 'example.com', 124 | integrated_security: 'true' 125 | ).jdbc_uri.should == 'jdbc:sqlserver://example.com;integratedSecurity=true' 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/cube_cache_control_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Cube" do 4 | before(:all) do 5 | @schema = Mondrian::OLAP::Schema.define do 6 | measures_caption 'Measures caption' 7 | 8 | cube 'Sales' do 9 | description 'Sales description' 10 | caption 'Sales caption' 11 | annotations :foo => 'bar' 12 | table 'sales' 13 | visible true 14 | dimension 'Gender', :foreign_key => 'customer_id' do 15 | description 'Gender description' 16 | caption 'Gender caption' 17 | visible true 18 | hierarchy :has_all => true, :primary_key => 'id' do 19 | description 'Gender hierarchy description' 20 | caption 'Gender hierarchy caption' 21 | all_member_name 'All Genders' 22 | all_member_caption 'All Genders caption' 23 | table 'customers' 24 | visible true 25 | level 'Gender', :column => 'gender', :unique_members => true, 26 | :description => 'Gender level description', :caption => 'Gender level caption' do 27 | visible true 28 | # Dimension values SQL generated by caption_expression fails on PostgreSQL and MS SQL 29 | if %w(mysql oracle).include?(MONDRIAN_DRIVER) 30 | caption_expression do 31 | sql "'dummy'" 32 | end 33 | end 34 | end 35 | end 36 | end 37 | dimension 'Customers', :foreign_key => 'customer_id', :annotations => {:foo => 'bar'} do 38 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id', :annotations => {:foo => 'bar'} do 39 | table 'customers' 40 | level 'Country', :column => 'country', :unique_members => true, :annotations => {:foo => 'bar'} 41 | level 'State Province', :column => 'state_province', :unique_members => true 42 | level 'City', :column => 'city', :unique_members => false 43 | level 'Name', :column => 'fullname', :unique_members => true 44 | end 45 | end 46 | calculated_member 'Non-USA', :annotations => {:foo => 'bar'} do 47 | dimension 'Customers' 48 | formula '[Customers].[All Customers] - [Customers].[USA]' 49 | end 50 | dimension 'Time', :foreign_key => 'time_id', :type => 'TimeDimension' do 51 | hierarchy :has_all => false, :primary_key => 'id' do 52 | table 'time' 53 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 54 | level 'Quarter', :column => 'quarter', :unique_members => false, :level_type => 'TimeQuarters' 55 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeMonths' 56 | end 57 | hierarchy 'Weekly', :has_all => false, :primary_key => 'id' do 58 | table 'time' 59 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 60 | level 'Week', :column => 'weak_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeWeeks' 61 | end 62 | end 63 | calculated_member 'Last week' do 64 | hierarchy '[Time.Weekly]' 65 | formula 'Tail([Time.Weekly].[Week].Members).Item(0)' 66 | end 67 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum', :annotations => {:foo => 'bar'} 68 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 69 | measure 'Store Cost', :column => 'store_cost', :aggregator => 'sum', :visible => false 70 | end 71 | end 72 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 73 | end 74 | 75 | # Do not execute tests on analytical databases with slow individual inserts 76 | describe 'cache', unless: %w(vertica snowflake clickhouse mariadb).include?(MONDRIAN_DRIVER) do 77 | def qt(name) 78 | @connection.quote_table_name(name.to_s) 79 | end 80 | 81 | before(:all) do 82 | @connection = ActiveRecord::Base.connection 83 | @cube = @olap.cube('Sales') 84 | @query = <<-SQL 85 | SELECT {[Measures].[Store Cost], [Measures].[Store Sales]} ON COLUMNS 86 | FROM [Sales] 87 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 88 | SQL 89 | 90 | case MONDRIAN_DRIVER 91 | when 'mysql', 'jdbc_mysql', 'postgresql', 'oracle' 92 | @connection.execute 'CREATE TABLE sales_copy AS SELECT * FROM sales' 93 | when 'sqlserver' 94 | # Use raw_connection.execute to avoid detecting this query as a SELECT query 95 | # for which executeQuery JDBC method will fail 96 | @connection.raw_connection.execute_update 'SELECT * INTO sales_copy FROM sales' 97 | end 98 | end 99 | 100 | after(:each) do 101 | @connection.execute 'TRUNCATE TABLE sales' 102 | @connection.execute 'INSERT INTO sales SELECT * FROM sales_copy' 103 | 104 | @olap.flush_schema_cache 105 | @olap.close 106 | @olap.connect 107 | end 108 | 109 | after(:all) do 110 | @connection.execute 'DROP TABLE sales_copy' 111 | end 112 | 113 | it 'should clear cache for deleted data at lower level with segments' do 114 | @olap.execute(@query).values.should == [6890.553, 11390.4] 115 | @connection.execute <<-SQL 116 | DELETE FROM sales 117 | WHERE time_id IN (SELECT id 118 | FROM #{qt :time} 119 | WHERE the_year = 2010 120 | AND quarter = 'Q1') 121 | AND customer_id IN (SELECT id 122 | FROM customers 123 | WHERE country = 'USA' 124 | AND state_province = 'CA' 125 | AND city = 'Berkeley') 126 | SQL 127 | @cube.flush_region_cache_with_segments(%w(Time 2010 Q1), %w(Customers USA CA)) 128 | @olap.execute(@query).values.should == [6756.4296, 11156.28] 129 | end 130 | 131 | it 'should clear cache for deleted data at same level with segments' do 132 | @olap.execute(@query).values.should == [6890.553, 11390.4] 133 | @connection.execute <<-SQL 134 | DELETE FROM sales 135 | WHERE time_id IN (SELECT id 136 | FROM #{qt :time} 137 | WHERE the_year = 2010 138 | AND quarter = 'Q1') 139 | AND customer_id IN (SELECT id 140 | FROM customers 141 | WHERE country = 'USA' 142 | AND state_province = 'CA') 143 | SQL 144 | @cube.flush_region_cache_with_segments(%w(Time 2010 Q1), %w(Customers USA CA)) 145 | @olap.execute(@query).values.should == [nil, nil] 146 | end 147 | 148 | it 'should clear cache for update data at lower level with segments' do 149 | @olap.execute(@query).values.should == [6890.553, 11390.4] 150 | @connection.execute <<-SQL 151 | UPDATE sales SET 152 | store_sales = store_sales + 1, 153 | store_cost = store_cost + 1 154 | WHERE time_id IN (SELECT id 155 | FROM #{qt :time} 156 | WHERE the_year = 2010 157 | AND quarter = 'Q1') 158 | AND customer_id IN (SELECT id 159 | FROM customers 160 | WHERE country = 'USA' 161 | AND state_province = 'CA' 162 | AND city = 'Berkeley') 163 | SQL 164 | @cube.flush_region_cache_with_segments(%w(Time 2010 Q1), %w(Customers USA CA)) 165 | @olap.execute(@query).values.should == [6891.553, 11391.4] 166 | end 167 | 168 | it 'should clear cache for update data at same level with segments' do 169 | @olap.execute(@query).values.should == [6890.553, 11390.4] 170 | @connection.execute <<-SQL 171 | UPDATE sales SET 172 | store_sales = store_sales + 1, 173 | store_cost = store_cost + 1 174 | WHERE time_id IN (SELECT id 175 | FROM #{qt :time} 176 | WHERE the_year = 2010 177 | AND quarter = 'Q1') 178 | AND customer_id IN (SELECT id 179 | FROM customers 180 | WHERE country = 'USA' 181 | AND state_province = 'CA') 182 | SQL 183 | @cube.flush_region_cache_with_segments(%w(Time 2010 Q1), %w(Customers USA CA)) 184 | @olap.execute(@query).values.should == [6935.553, 11435.4] 185 | end 186 | 187 | it 'should clear cache for deleted data at lower level with members' do 188 | @olap.execute(@query).values.should == [6890.553, 11390.4] 189 | @connection.execute <<-SQL 190 | DELETE FROM sales 191 | WHERE time_id IN (SELECT id 192 | FROM #{qt :time} 193 | WHERE the_year = 2010 194 | AND quarter = 'Q1') 195 | AND customer_id IN (SELECT id 196 | FROM customers 197 | WHERE country = 'USA' 198 | AND state_province = 'CA' 199 | AND city = 'Berkeley') 200 | SQL 201 | @cube.flush_region_cache_with_full_names('[Time].[2010].[Q1]', '[Customers].[USA].[CA]') 202 | @olap.execute(@query).values.should == [6756.4296, 11156.28] 203 | end 204 | 205 | it 'should clear cache for deleted data at same level with members' do 206 | @olap.execute(@query).values.should == [6890.553, 11390.4] 207 | @connection.execute <<-SQL 208 | DELETE FROM sales 209 | WHERE time_id IN (SELECT id 210 | FROM #{qt :time} 211 | WHERE the_year = 2010 212 | AND quarter = 'Q1') 213 | AND customer_id IN (SELECT id 214 | FROM customers 215 | WHERE country = 'USA' 216 | AND state_province = 'CA') 217 | SQL 218 | @cube.flush_region_cache_with_full_names('[Time].[2010].[Q1]', '[Customers].[USA].[CA]') 219 | @olap.execute(@query).values.should == [nil, nil] 220 | end 221 | 222 | it 'should clear cache for update data at lower level with members' do 223 | @olap.execute(@query).values.should == [6890.553, 11390.4] 224 | @connection.execute <<-SQL 225 | UPDATE sales SET 226 | store_sales = store_sales + 1, 227 | store_cost = store_cost + 1 228 | WHERE time_id IN (SELECT id 229 | FROM #{qt :time} 230 | WHERE the_year = 2010 231 | AND quarter = 'Q1') 232 | AND customer_id IN (SELECT id 233 | FROM customers 234 | WHERE country = 'USA' 235 | AND state_province = 'CA' 236 | AND city = 'Berkeley') 237 | SQL 238 | @cube.flush_region_cache_with_full_names('[Time].[2010].[Q1]', '[Customers].[USA].[CA]') 239 | @olap.execute(@query).values.should == [6891.553, 11391.4] 240 | end 241 | 242 | it 'should clear cache for update data at same level with members' do 243 | @olap.execute(@query).values.should == [6890.553, 11390.4] 244 | @connection.execute <<-SQL 245 | UPDATE sales SET 246 | store_sales = store_sales + 1, 247 | store_cost = store_cost + 1 248 | WHERE time_id IN (SELECT id 249 | FROM #{qt :time} 250 | WHERE the_year = 2010 251 | AND quarter = 'Q1') 252 | AND customer_id IN (SELECT id 253 | FROM customers 254 | WHERE country = 'USA' 255 | AND state_province = 'CA') 256 | SQL 257 | @cube.flush_region_cache_with_full_names('[Time].[2010].[Q1]', '[Customers].[USA].[CA]') 258 | @olap.execute(@query).values.should == [6935.553, 11435.4] 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /spec/cube_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Cube" do 4 | before(:all) do 5 | @schema = Mondrian::OLAP::Schema.define do 6 | measures_caption 'Measures caption' 7 | 8 | cube 'Sales' do 9 | description 'Sales description' 10 | caption 'Sales caption' 11 | annotations :foo => 'bar' 12 | table 'sales' 13 | visible true 14 | dimension 'Gender', :foreign_key => 'customer_id' do 15 | description 'Gender description' 16 | caption 'Gender caption' 17 | visible true 18 | hierarchy :has_all => true, :primary_key => 'id' do 19 | description 'Gender hierarchy description' 20 | caption 'Gender hierarchy caption' 21 | all_member_name 'All Genders' 22 | all_member_caption 'All Genders caption' 23 | table 'customers' 24 | visible true 25 | level 'Gender', :column => 'gender', :unique_members => true, 26 | :description => 'Gender level description', :caption => 'Gender level caption' do 27 | visible true 28 | # Dimension values SQL generated by caption_expression fails on PostgreSQL and MS SQL 29 | if %w(mysql oracle).include?(MONDRIAN_DRIVER) 30 | caption_expression do 31 | sql "'dummy'" 32 | end 33 | end 34 | end 35 | end 36 | end 37 | dimension 'Customers', :foreign_key => 'customer_id', :annotations => {:foo => 'bar'} do 38 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id', :annotations => {:foo => 'bar'} do 39 | table 'customers' 40 | level 'Country', :column => 'country', :unique_members => true, :annotations => {:foo => 'bar'} 41 | level 'State Province', :column => 'state_province', :unique_members => true 42 | level 'City', :column => 'city', :unique_members => false 43 | level 'Name', :column => 'fullname', :unique_members => true 44 | end 45 | end 46 | calculated_member 'Non-USA', :annotations => {:foo => 'bar'} do 47 | dimension 'Customers' 48 | formula '[Customers].[All Customers] - [Customers].[USA]' 49 | end 50 | dimension 'Time', :foreign_key => 'time_id', :type => 'TimeDimension' do 51 | hierarchy :has_all => false, :primary_key => 'id' do 52 | table 'time' 53 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 54 | level 'Quarter', :column => 'quarter', :unique_members => false, :level_type => 'TimeQuarters' 55 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeMonths' 56 | end 57 | hierarchy 'Weekly', :has_all => false, :primary_key => 'id' do 58 | table 'time' 59 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 60 | level 'Week', :column => 'weak_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeWeeks' 61 | end 62 | end 63 | calculated_member 'Last week' do 64 | hierarchy '[Time.Weekly]' 65 | formula 'Tail([Time.Weekly].[Week].Members).Item(0)' 66 | end 67 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum', :annotations => {:foo => 'bar'} 68 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 69 | measure 'Store Cost', :column => 'store_cost', :aggregator => 'sum', :visible => false 70 | end 71 | end 72 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 73 | end 74 | 75 | it "should get all cube names" do 76 | @olap.cube_names.should == ['Sales'] 77 | end 78 | 79 | it "should get cube by name" do 80 | @olap.cube('Sales').should be_a(Mondrian::OLAP::Cube) 81 | end 82 | 83 | it "should return nil when getting cube with invalid name" do 84 | @olap.cube('invalid').should be_nil 85 | end 86 | 87 | it "should get cube name" do 88 | @olap.cube('Sales').name.should == 'Sales' 89 | end 90 | 91 | it "should get cube description" do 92 | @olap.cube('Sales').description.should == 'Sales description' 93 | end 94 | 95 | it "should get cube caption" do 96 | @olap.cube('Sales').caption.should == 'Sales caption' 97 | end 98 | 99 | it "should get cube annotations" do 100 | @olap.cube('Sales').annotations.should == {'foo' => 'bar'} 101 | end 102 | 103 | it "should be visible" do 104 | @olap.cube('Sales').should be_visible 105 | end 106 | 107 | describe "dimensions" do 108 | before(:all) do 109 | @cube = @olap.cube('Sales') 110 | @dimension_names = ['Measures', 'Gender', 'Customers', 'Time'] 111 | end 112 | 113 | it "should get dimension names" do 114 | @cube.dimension_names.should == @dimension_names 115 | end 116 | 117 | it "should get dimensions" do 118 | @cube.dimensions.map{|d| d.name}.should == @dimension_names 119 | end 120 | 121 | it "should get dimension by name" do 122 | @cube.dimension('Gender').name.should == 'Gender' 123 | end 124 | 125 | it "should return nil when getting dimension with invalid name" do 126 | @cube.dimension('invalid').should be_nil 127 | end 128 | 129 | it "should get dimension description" do 130 | @cube.dimension('Gender').description.should == 'Gender description' 131 | end 132 | 133 | it "should get dimension caption" do 134 | @cube.dimension('Gender').caption.should == 'Gender caption' 135 | end 136 | 137 | it "should get dimension full name" do 138 | @cube.dimension('Gender').full_name.should == '[Gender]' 139 | end 140 | 141 | it "should get measures dimension" do 142 | @cube.dimension('Measures').should be_measures 143 | end 144 | 145 | it "should get measures caption" do 146 | @cube.dimension('Measures').caption.should == 'Measures caption' 147 | end 148 | 149 | it "should get dimension type" do 150 | @cube.dimension('Gender').dimension_type.should == :standard 151 | @cube.dimension('Time').dimension_type.should == :time 152 | @cube.dimension('Measures').dimension_type.should == :measures 153 | end 154 | 155 | it "should get dimension annotations" do 156 | @cube.dimension('Customers').annotations.should == {'foo' => 'bar'} 157 | end 158 | 159 | it "should get dimension empty annotations" do 160 | @cube.dimension('Gender').annotations.should == {} 161 | end 162 | 163 | it "should be visible" do 164 | @cube.dimension('Gender').should be_visible 165 | end 166 | 167 | end 168 | 169 | describe "cube hierarchies" do 170 | before(:all) do 171 | @cube = @olap.cube('Sales') 172 | @hierarchy_names = %w(Measures Gender Customers Time Time.Weekly) 173 | end 174 | 175 | it "should get hierarchy names" do 176 | @cube.hierarchy_names.should == @hierarchy_names 177 | end 178 | 179 | it "should get hierarchy by name" do 180 | @cube.hierarchy('Gender').name.should == 'Gender' 181 | end 182 | 183 | it "should get hierarchy dimension name" do 184 | hierarchy = @cube.hierarchy('Time.Weekly') 185 | hierarchy.dimension.name.should == 'Time' 186 | hierarchy.dimension_name.should == 'Time' 187 | end 188 | 189 | it "should return nil when getting dimension with invalid name" do 190 | @cube.hierarchy('invalid').should be_nil 191 | end 192 | end 193 | 194 | describe "dimension hierarchies" do 195 | before(:all) do 196 | @cube = @olap.cube('Sales') 197 | end 198 | 199 | it "should get hierarchies" do 200 | hierarchies = @cube.dimension('Gender').hierarchies 201 | hierarchies.size.should == 1 202 | hierarchies[0].name.should == 'Gender' 203 | end 204 | 205 | it "should get hierarchy description" do 206 | hierarchies = @cube.dimension('Gender').hierarchies.first.description.should == 'Gender hierarchy description' 207 | end 208 | 209 | it "should get hierarchy caption" do 210 | hierarchies = @cube.dimension('Gender').hierarchies.first.caption.should == 'Gender hierarchy caption' 211 | end 212 | 213 | it "should get hierarchy names" do 214 | @cube.dimension('Time').hierarchy_names.should == ['Time', 'Time.Weekly'] 215 | end 216 | 217 | it "should get hierarchy by name" do 218 | @cube.dimension('Time').hierarchy('Time.Weekly').name.should == 'Time.Weekly' 219 | end 220 | 221 | it "should return nil when getting hierarchy with invalid name" do 222 | @cube.dimension('Time').hierarchy('invalid').should be_nil 223 | end 224 | 225 | it "should get default hierarchy" do 226 | @cube.dimension('Time').hierarchy.name.should == 'Time' 227 | end 228 | 229 | it "should get hierarchy levels" do 230 | @cube.dimension('Customers').hierarchy.levels.map(&:name).should == ['(All)', 'Country', 'State Province', 'City', 'Name'] 231 | end 232 | 233 | it "should get hierarchy level names" do 234 | @cube.dimension('Time').hierarchy.level_names.should == ['Year', 'Quarter', 'Month'] 235 | @cube.dimension('Customers').hierarchy.level_names.should == ['(All)', 'Country', 'State Province', 'City', 'Name'] 236 | end 237 | 238 | it "should get hierarchy level depths" do 239 | @cube.dimension('Customers').hierarchy.levels.map(&:depth).should == [0, 1, 2, 3, 4] 240 | end 241 | 242 | it "should get hierarchy level members count" do 243 | @cube.dimension('Gender').hierarchy.levels.map(&:members_count).should == [1, 2] 244 | end 245 | 246 | it "should set and get hierarchy level cardinality" do 247 | level = @cube.dimension('Gender').hierarchy.levels.last 248 | level.cardinality.should == Java::JavaLang::Integer::MIN_VALUE 249 | level.cardinality = 2 250 | @olap.cube('Sales').dimension('Gender').hierarchy.levels.last.cardinality.should == 2 251 | level.cardinality = nil 252 | @olap.cube('Sales').dimension('Gender').hierarchy.levels.last.cardinality.should == Java::JavaLang::Integer::MIN_VALUE 253 | end 254 | 255 | it "should get hierarchy annotations" do 256 | @cube.dimension('Customers').hierarchy.annotations.should == {'foo' => 'bar'} 257 | end 258 | 259 | it "should get hierarchy empty annotations" do 260 | @cube.dimension('Gender').hierarchy.annotations.should == {} 261 | end 262 | 263 | it "should be visible" do 264 | @cube.dimension('Gender').hierarchies.first.should be_visible 265 | end 266 | 267 | end 268 | 269 | describe "hierarchy values" do 270 | before(:all) do 271 | @cube = @olap.cube('Sales') 272 | end 273 | 274 | it "should get hierarchy all member" do 275 | @cube.dimension('Gender').hierarchy.has_all?.should == true 276 | @cube.dimension('Gender').hierarchy.all_member_name.should == 'All Genders' 277 | end 278 | 279 | it "should not get all member for hierarchy without all member" do 280 | @cube.dimension('Time').hierarchy.has_all?.should == false 281 | @cube.dimension('Time').hierarchy.all_member_name.should be_nil 282 | end 283 | 284 | it "should get hierarchy root members" do 285 | @cube.dimension('Gender').hierarchy.root_members.map(&:name).should == ['All Genders'] 286 | @cube.dimension('Gender').hierarchy.root_member_names.should == ['All Genders'] 287 | @cube.dimension('Time').hierarchy.root_members.map(&:name).should == ['2010', '2011'] 288 | @cube.dimension('Time').hierarchy.root_member_names.should == ['2010', '2011'] 289 | end 290 | 291 | it "should return child members for specified member" do 292 | @cube.dimension('Gender').hierarchy.child_names('All Genders').should == ['F', 'M'] 293 | @cube.dimension('Customers').hierarchy.child_names('USA', 'OR').should == 294 | ["Albany", "Beaverton", "Corvallis", "Lake Oswego", "Lebanon", "Milwaukie", 295 | "Oregon City", "Portland", "Salem", "W. Linn", "Woodburn"] 296 | end 297 | 298 | it "should return child members for hierarchy" do 299 | @cube.dimension('Gender').hierarchy.child_names.should == ['F', 'M'] 300 | end 301 | 302 | it "should not return child members for leaf member" do 303 | @cube.dimension('Gender').hierarchy.child_names('All Genders', 'F').should == [] 304 | end 305 | 306 | it "should return nil as child members if parent member not found" do 307 | @cube.dimension('Gender').hierarchy.child_names('N').should be_nil 308 | end 309 | 310 | end 311 | 312 | describe "hierarchy levels" do 313 | before(:all) do 314 | @cube = @olap.cube('Sales') 315 | end 316 | 317 | it "should get level description" do 318 | @cube.dimension('Gender').hierarchy.level('Gender').description.should == 'Gender level description' 319 | end 320 | 321 | it "should get level caption" do 322 | @cube.dimension('Gender').hierarchy.level('Gender').caption.should == 'Gender level caption' 323 | end 324 | 325 | it "should return nil when getting level with invalid name" do 326 | @cube.dimension('Gender').hierarchy.level('invalid').should be_nil 327 | end 328 | 329 | it "should get primary hierarchy level members" do 330 | @cube.dimension('Customers').hierarchy.level('Country').members. 331 | map(&:name).should == ['Canada', 'Mexico', 'USA'] 332 | end 333 | 334 | it "should get secondary hierarchy level members" do 335 | @cube.dimension('Time').hierarchy('Time.Weekly').level('Year').members. 336 | map(&:name).should == ['2010', '2011'] 337 | end 338 | 339 | it "should get level annotations" do 340 | @cube.dimension('Customers').hierarchy.level('Country').annotations.should == {'foo' => 'bar'} 341 | end 342 | 343 | it "should get level empty annotations" do 344 | @cube.dimension('Gender').hierarchy.level('Gender').annotations.should == {} 345 | end 346 | 347 | it "should be visible" do 348 | @cube.dimension('Gender').hierarchy.level('Gender').should be_visible 349 | end 350 | 351 | end 352 | 353 | describe "members" do 354 | before(:all) do 355 | @cube = @olap.cube('Sales') 356 | end 357 | 358 | it "should return member for specified full name" do 359 | @cube.member('[Gender].[All Genders]').name.should == 'All Genders' 360 | @cube.member('[Customers].[USA].[OR]').name.should == 'OR' 361 | end 362 | 363 | it "should return all member caption" do 364 | @cube.member('[Gender].[All Genders]').caption.should == 'All Genders caption' 365 | end 366 | 367 | it "should return member caption from expression" do 368 | @cube.member('[Gender].[F]').caption.should == 369 | (%w(mysql oracle).include?(MONDRIAN_DRIVER) ? 'dummy' : 'F') 370 | end 371 | 372 | it "should not return member for invalid full name" do 373 | @cube.member('[Gender].[invalid]').should be_nil 374 | end 375 | 376 | it "should return child members for member" do 377 | @cube.member('[Gender].[All Genders]').children.map(&:name).should == ['F', 'M'] 378 | @cube.member('[Customers].[USA].[OR]').children.map(&:name).should == 379 | ["Albany", "Beaverton", "Corvallis", "Lake Oswego", "Lebanon", "Milwaukie", 380 | "Oregon City", "Portland", "Salem", "W. Linn", "Woodburn"] 381 | end 382 | 383 | it "should return empty children array if member does not have children" do 384 | @cube.member('[Gender].[All Genders].[F]').children.should be_empty 385 | end 386 | 387 | it "should return member depth" do 388 | @cube.member('[Customers].[All Customers]').depth.should == 0 389 | @cube.member('[Customers].[USA]').depth.should == 1 390 | @cube.member('[Customers].[USA].[CA]').depth.should == 2 391 | end 392 | 393 | it "should return descendants for member at specified level" do 394 | @cube.member('[Customers].[Mexico]').descendants_at_level('City').map(&:name).should == 395 | ["San Andres", "Santa Anita", "Santa Fe", "Tixapan", "Acapulco", "Guadalajara", 396 | "Mexico City", "Tlaxiaco", "La Cruz", "Orizaba", "Merida", "Camacho", "Hidalgo"] 397 | end 398 | 399 | it "should not return descendants for member when upper level specified" do 400 | @cube.member('[Customers].[Mexico].[DF]').descendants_at_level('Country').should be_nil 401 | end 402 | 403 | it "should be drillable when member has descendants" do 404 | @cube.member('[Customers].[USA]').should be_drillable 405 | end 406 | 407 | it "should not be drillable when member has no descendants" do 408 | @cube.member('[Gender].[F]').should_not be_drillable 409 | end 410 | 411 | it "should not be drillable when member is calculated" do 412 | @cube.member('[Customers].[Non-USA]').should_not be_drillable 413 | end 414 | 415 | it "should be calculated when member is calculated" do 416 | @cube.member('[Customers].[Non-USA]').should be_calculated 417 | end 418 | 419 | it "should be calculated when member is calculated in non-default hierarchy" do 420 | @cube.member('[Time.Weekly].[Last week]').should be_calculated 421 | end 422 | 423 | it "should not be calculated in query when calculated member defined in schema" do 424 | @cube.member('[Customers].[Non-USA]').should_not be_calculated_in_query 425 | end 426 | 427 | it "should not be calculated when normal member" do 428 | @cube.member('[Customers].[USA]').should_not be_calculated 429 | end 430 | 431 | it "should be all member when member is all member" do 432 | @cube.member('[Customers].[All Customers]').should be_all_member 433 | end 434 | 435 | it "should not be all member when member is not all member" do 436 | @cube.member('[Customers].[USA]').should_not be_all_member 437 | end 438 | 439 | it "should get dimension type of standard dimension member" do 440 | @cube.member('[Customers].[USA]').dimension_type.should == :standard 441 | end 442 | 443 | it "should get dimension type of measure" do 444 | @cube.member('[Measures].[Unit Sales]').dimension_type.should == :measures 445 | end 446 | 447 | it "should get dimension type of time dimension member" do 448 | @cube.member('[Time].[2011]').dimension_type.should == :time 449 | end 450 | 451 | it "should be visible when member is visible" do 452 | @cube.member('[Measures].[Store Sales]').should be_visible 453 | end 454 | 455 | it "should not be visible when member is not visible" do 456 | @cube.member('[Measures].[Store Cost]').should_not be_visible 457 | end 458 | 459 | it "should get measure annotations" do 460 | @cube.member('[Measures].[Unit Sales]').annotations.should == {'foo' => 'bar'} 461 | end 462 | 463 | it "should get measure empty annotations" do 464 | @cube.member('[Measures].[Store Sales]').annotations.should == {} 465 | end 466 | 467 | it "should get member empty annotations" do 468 | @cube.member('[Customers].[USA]').annotations.should == {} 469 | end 470 | end 471 | end 472 | -------------------------------------------------------------------------------- /spec/fixtures/MondrianTest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 13 | 14 |
15 | 17 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 32 | 34 | 36 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | fname || ' ' || lname 54 | 55 | 56 | "fname" || ' ' || "lname" 57 | 58 | 59 | CONCAT(`customers`.`fname`, ' ', `customers`.`lname`) 60 | 61 | 62 | fullname 63 | 64 | 65 | 66 | 67 | fname || ' ' || lname 68 | 69 | 70 | "fname" || ' ' || "lname" 71 | 72 | 73 | CONCAT(`customers`.`fname`, ' ', `customers`.`lname`) 74 | 75 | 76 | fullname 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 | 88 | 89 | 90 | 91 | 92 |
93 | 94 | 95 | 96 | 97 | 99 | 101 | 103 | 105 | 107 | 110 | [Measures].[Store Sales] - [Measures].[Store Cost] 111 | 112 | 113 | 118 | 119 | 120 | 121 | 127 | 128 | 129 | 130 | 131 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | CASE WHEN units_shipped IS NOT NULL THEN product_id END 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /spec/fixtures/MondrianTestOracle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 8 | 10 | 12 | 13 | 14 |
15 | 17 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 32 | 34 | 36 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | fname || ' ' || lname 54 | 55 | 56 | "fname" || ' ' || "lname" 57 | 58 | 59 | CONCAT(`customer`.`fname`, ' ', `customer`.`lname`) 60 | 61 | 62 | FULLNAME 63 | 64 | 65 | 66 | 67 | fname || ' ' || lname 68 | 69 | 70 | "fname" || ' ' || "lname" 71 | 72 | 73 | CONCAT(`customer`.`fname`, ' ', `customer`.`lname`) 74 | 75 | 76 | FULLNAME 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 | 88 | 89 | 90 | 91 | 92 |
93 | 94 | 95 | 96 | 97 | 99 | 101 | 103 | 105 | 107 | 110 | [Measures].[Store Sales] - [Measures].[Store Cost] 111 | 112 | 113 | 118 | 119 | 120 | 121 | 127 | 128 | 129 | 130 | 131 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | CASE WHEN UNITS_SHIPPED IS NOT NULL THEN PRODUCT_ID END 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /spec/rake_tasks.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | namespace :db do 4 | task :require_spec_helper do 5 | require File.expand_path("../spec_helper", __FILE__) 6 | end 7 | 8 | import_data_drivers = %w(vertica snowflake clickhouse mariadb) 9 | 10 | desc "Create test database tables" 11 | task :create_tables => :require_spec_helper do 12 | puts "==> Creating tables for test data" 13 | ActiveRecord::Schema.instance_eval do 14 | 15 | create_table :time, :force => true do |t| 16 | t.datetime :the_date 17 | t.string :the_day, :limit => 30 18 | t.string :the_month, :limit => 30 19 | t.integer :the_year 20 | t.integer :day_of_month 21 | t.integer :week_of_year 22 | t.integer :month_of_year 23 | t.string :quarter, :limit => 30 24 | end 25 | 26 | create_table :products, :force => true do |t| 27 | t.integer :product_class_id 28 | t.string :brand_name, :limit => 60 29 | t.string :product_name, :limit => 60 30 | end 31 | 32 | create_table :product_classes, :force => true do |t| 33 | t.string :product_subcategory, :limit => 30 34 | t.string :product_category, :limit => 30 35 | t.string :product_department, :limit => 30 36 | t.string :product_family, :limit => 30 37 | end 38 | 39 | customers_options = {force: true} 40 | customers_options[:id] = false if import_data_drivers.include?(MONDRIAN_DRIVER) 41 | create_table :customers, **customers_options do |t| 42 | t.integer :id, :limit => 8 if import_data_drivers.include?(MONDRIAN_DRIVER) 43 | t.string :country, :limit => 30 44 | t.string :state_province, :limit => 30 45 | t.string :city, :limit => 30 46 | t.string :fname, :limit => 30 47 | t.string :lname, :limit => 30 48 | t.string :fullname, :limit => 60 49 | t.string :gender, :limit => 30 50 | t.date :birthdate 51 | t.integer :promotion_id 52 | t.string :related_fullname, :limit => 60 53 | # Mondrian does not support properties with Oracle CLOB type 54 | # as it tries to GROUP BY all columns when loading a dimension table 55 | if MONDRIAN_DRIVER == 'oracle' 56 | t.string :description, :limit => 4000 57 | else 58 | t.text :description 59 | end 60 | end 61 | 62 | if MONDRIAN_DRIVER == 'oracle' 63 | 64 | execute "DROP TABLE PROMOTIONS" rescue nil 65 | execute "DROP SEQUENCE PROMOTIONS_SEQ" rescue nil 66 | 67 | execute <<~SQL 68 | CREATE TABLE PROMOTIONS( 69 | ID NUMBER(*,0) NOT NULL, 70 | PROMOTION VARCHAR2(30 CHAR), 71 | SEQUENCE NUMBER(38,0), 72 | PRIMARY KEY ("ID") 73 | ) 74 | SQL 75 | execute "CREATE SEQUENCE PROMOTIONS_SEQ" 76 | else 77 | create_table :promotions, :force => true do |t| 78 | t.string :promotion, :limit => 30 79 | t.integer :sequence 80 | end 81 | end 82 | 83 | case MONDRIAN_DRIVER 84 | when /mysql/ 85 | execute "ALTER TABLE customers MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT" 86 | when /postgresql/ 87 | execute "ALTER TABLE customers ALTER COLUMN id SET DATA TYPE bigint" 88 | when /sqlserver/ 89 | sql = "SELECT name FROM sysobjects WHERE xtype = 'PK' AND parent_obj=OBJECT_ID('customers')" 90 | primary_key_constraint = select_value(sql) 91 | execute "ALTER TABLE customers DROP CONSTRAINT #{primary_key_constraint}" 92 | execute "ALTER TABLE customers ALTER COLUMN id BIGINT" 93 | execute "ALTER TABLE customers ADD CONSTRAINT #{primary_key_constraint} PRIMARY KEY (id)" 94 | end 95 | 96 | create_table :sales, :force => true, :id => false do |t| 97 | t.integer :product_id 98 | t.integer :time_id 99 | t.integer :customer_id, limit: MONDRIAN_DRIVER == 'sqlserver' ? nil : 8 100 | t.integer :promotion_id 101 | t.decimal :store_sales, precision: 10, scale: 4 102 | t.decimal :store_cost, precision: 10, scale: 4 103 | t.decimal :unit_sales, precision: 10, scale: 4 104 | end 105 | 106 | case MONDRIAN_DRIVER 107 | when /sqlserver/ 108 | execute "ALTER TABLE sales ALTER COLUMN customer_id BIGINT" 109 | end 110 | 111 | create_table :warehouse, :force => true, :id => false do |t| 112 | t.integer :product_id 113 | t.integer :time_id 114 | t.integer :units_shipped 115 | t.decimal :store_invoice, precision: 10, scale: 4 116 | end 117 | end 118 | end 119 | 120 | task :define_models => :require_spec_helper do 121 | class TimeDimension < ActiveRecord::Base 122 | self.table_name = "time" 123 | validates_presence_of :the_date 124 | before_create do 125 | self.the_day = the_date.strftime("%A") 126 | self.the_month = the_date.strftime("%B") 127 | self.the_year = the_date.strftime("%Y").to_i 128 | self.day_of_month = the_date.strftime("%d").to_i 129 | self.week_of_year = the_date.strftime("%W").to_i 130 | self.month_of_year = the_date.strftime("%m").to_i 131 | self.quarter = "Q#{(month_of_year-1)/3+1}" 132 | end 133 | end 134 | class Product < ActiveRecord::Base 135 | belongs_to :product_class 136 | end 137 | class ProductClass < ActiveRecord::Base 138 | end 139 | class Customer < ActiveRecord::Base 140 | end 141 | class Promotion < ActiveRecord::Base 142 | end 143 | class Sales < ActiveRecord::Base 144 | self.table_name = "sales" 145 | belongs_to :time_by_day 146 | belongs_to :product 147 | belongs_to :customer 148 | end 149 | class Warehouse < ActiveRecord::Base 150 | self.table_name = "warehouse" 151 | belongs_to :time_by_day 152 | belongs_to :product 153 | end 154 | end 155 | 156 | desc "Create test data" 157 | task :create_data => [:create_tables] + (import_data_drivers.include?(ENV['MONDRIAN_DRIVER']) ? [:import_data] : 158 | [ :create_time_data, :create_product_data, :create_promotion_data, :create_customer_data, :create_sales_data, 159 | :create_warehouse_data ] ) 160 | 161 | task :create_time_data => :define_models do 162 | puts "==> Creating time dimension" 163 | TimeDimension.delete_all 164 | start_time = Time.utc(2010,1,1) 165 | (2*365).times do |i| 166 | TimeDimension.create!(:the_date => start_time + i.day) 167 | end 168 | end 169 | 170 | task :create_product_data => :define_models do 171 | puts "==> Creating product data" 172 | Product.delete_all 173 | ProductClass.delete_all 174 | families = ["Drink", "Food", "Non-Consumable"] 175 | (1..100).each do |i| 176 | product_class = ProductClass.create!( 177 | :product_family => families[i % 3], 178 | :product_department => "Department #{i}", 179 | :product_category => "Category #{i}", 180 | :product_subcategory => "Subcategory #{i}" 181 | ) 182 | Product.create!( 183 | :product_class_id => ProductClass.where(:product_category => "Category #{i}").to_a.first.id, 184 | :brand_name => "Brand #{i}", 185 | :product_name => "Product #{i}" 186 | ) 187 | end 188 | end 189 | 190 | task :create_promotion_data => :define_models do 191 | puts "==> Creating promotion data" 192 | Promotion.delete_all 193 | (1..10).each do |i| 194 | Promotion.create!(promotion: "Promotion #{i}", sequence: i) 195 | end 196 | end 197 | 198 | task :create_customer_data => :define_models do 199 | puts "==> Creating customer data" 200 | Customer.delete_all 201 | promotions = Promotion.order("id").to_a 202 | i = 0 203 | [ 204 | ["Canada", "BC", "Burnaby"],["Canada", "BC", "Cliffside"],["Canada", "BC", "Haney"],["Canada", "BC", "Ladner"], 205 | ["Canada", "BC", "Langford"],["Canada", "BC", "Langley"],["Canada", "BC", "Metchosin"],["Canada", "BC", "N. Vancouver"], 206 | ["Canada", "BC", "Newton"],["Canada", "BC", "Oak Bay"],["Canada", "BC", "Port Hammond"],["Canada", "BC", "Richmond"], 207 | ["Canada", "BC", "Royal Oak"],["Canada", "BC", "Shawnee"],["Canada", "BC", "Sooke"],["Canada", "BC", "Vancouver"], 208 | ["Canada", "BC", "Victoria"],["Canada", "BC", "Westminster"], 209 | ["Mexico", "DF", "San Andres"],["Mexico", "DF", "Santa Anita"],["Mexico", "DF", "Santa Fe"],["Mexico", "DF", "Tixapan"], 210 | ["Mexico", "Guerrero", "Acapulco"],["Mexico", "Jalisco", "Guadalajara"],["Mexico", "Mexico", "Mexico City"], 211 | ["Mexico", "Oaxaca", "Tlaxiaco"],["Mexico", "Sinaloa", "La Cruz"],["Mexico", "Veracruz", "Orizaba"], 212 | ["Mexico", "Yucatan", "Merida"],["Mexico", "Zacatecas", "Camacho"],["Mexico", "Zacatecas", "Hidalgo"], 213 | ["USA", "CA", "Altadena"],["USA", "CA", "Arcadia"],["USA", "CA", "Bellflower"],["USA", "CA", "Berkeley"], 214 | ["USA", "CA", "Beverly Hills"],["USA", "CA", "Burbank"],["USA", "CA", "Burlingame"],["USA", "CA", "Chula Vista"], 215 | ["USA", "CA", "Colma"],["USA", "CA", "Concord"],["USA", "CA", "Coronado"],["USA", "CA", "Daly City"], 216 | ["USA", "CA", "Downey"],["USA", "CA", "El Cajon"],["USA", "CA", "Fremont"],["USA", "CA", "Glendale"], 217 | ["USA", "CA", "Grossmont"],["USA", "CA", "Imperial Beach"],["USA", "CA", "La Jolla"],["USA", "CA", "La Mesa"], 218 | ["USA", "CA", "Lakewood"],["USA", "CA", "Lemon Grove"],["USA", "CA", "Lincoln Acres"],["USA", "CA", "Long Beach"], 219 | ["USA", "CA", "Los Angeles"],["USA", "CA", "Mill Valley"],["USA", "CA", "National City"],["USA", "CA", "Newport Beach"], 220 | ["USA", "CA", "Novato"],["USA", "CA", "Oakland"],["USA", "CA", "Palo Alto"],["USA", "CA", "Pomona"], 221 | ["USA", "CA", "Redwood City"],["USA", "CA", "Richmond"],["USA", "CA", "San Carlos"],["USA", "CA", "San Diego"], 222 | ["USA", "CA", "San Francisco"],["USA", "CA", "San Gabriel"],["USA", "CA", "San Jose"],["USA", "CA", "Santa Cruz"], 223 | ["USA", "CA", "Santa Monica"],["USA", "CA", "Spring Valley"],["USA", "CA", "Torrance"],["USA", "CA", "West Covina"], 224 | ["USA", "CA", "Woodland Hills"], 225 | ["USA", "OR", "Albany"],["USA", "OR", "Beaverton"],["USA", "OR", "Corvallis"],["USA", "OR", "Lake Oswego"], 226 | ["USA", "OR", "Lebanon"],["USA", "OR", "Milwaukie"],["USA", "OR", "Oregon City"],["USA", "OR", "Portland"], 227 | ["USA", "OR", "Salem"],["USA", "OR", "W. Linn"],["USA", "OR", "Woodburn"], 228 | ["USA", "WA", "Anacortes"],["USA", "WA", "Ballard"],["USA", "WA", "Bellingham"],["USA", "WA", "Bremerton"], 229 | ["USA", "WA", "Burien"],["USA", "WA", "Edmonds"],["USA", "WA", "Everett"],["USA", "WA", "Issaquah"], 230 | ["USA", "WA", "Kirkland"],["USA", "WA", "Lynnwood"],["USA", "WA", "Marysville"],["USA", "WA", "Olympia"], 231 | ["USA", "WA", "Port Orchard"],["USA", "WA", "Puyallup"],["USA", "WA", "Redmond"],["USA", "WA", "Renton"], 232 | ["USA", "WA", "Seattle"],["USA", "WA", "Sedro Woolley"],["USA", "WA", "Spokane"],["USA", "WA", "Tacoma"], 233 | ["USA", "WA", "Walla Walla"],["USA", "WA", "Yakima"] 234 | ].each do |country, state, city| 235 | i += 1 236 | Customer.create!( 237 | :country => country, 238 | :state_province => state, 239 | :city => city, 240 | :fname => "First#{i}", 241 | :lname => "Last#{i}", 242 | :fullname => "First#{i} Last#{i}", 243 | :gender => i % 2 == 0 ? "M" : "F", 244 | :birthdate => Date.new(1970, 1, 1) + i, 245 | :promotion_id => promotions[i % 10].id, 246 | :related_fullname => "First#{i} Last#{i}", 247 | :description => 100.times.map{"1234567890"}.join("\n") 248 | ) 249 | end 250 | # Create additional customer with large ID 251 | attributes = { 252 | :id => 10_000_000_000, 253 | :country => "USA", 254 | :state_province => "CA", 255 | :city => "Rīga", # For testing UTF-8 characters 256 | :fname => "Big", 257 | :lname => "Number", 258 | :fullname => "Big Number", 259 | :gender => "M", 260 | :promotion_id => promotions.first.id, 261 | :related_fullname => "Big Number" 262 | } 263 | case MONDRIAN_DRIVER 264 | when 'sqlserver' 265 | Customer.connection.execute "SET IDENTITY_INSERT customers ON" 266 | Customer.create!(attributes) 267 | Customer.connection.execute "SET IDENTITY_INSERT customers OFF" 268 | else 269 | Customer.create!(attributes) 270 | end 271 | end 272 | 273 | task :create_sales_data => :define_models do 274 | puts "==> Creating sales data" 275 | Sales.delete_all 276 | count = 100 277 | products = Product.order("id").to_a[0...count] 278 | times = TimeDimension.order("id").to_a[0...count] 279 | customers = Customer.order("id").to_a[0...count] 280 | promotions = Promotion.order("id").to_a[0...count] 281 | count.times do |i| 282 | Sales.create!( 283 | :product_id => products[i].id, 284 | :time_id => times[i].id, 285 | :customer_id => customers[i].id, 286 | :promotion_id => promotions[i % 10].id, 287 | :store_sales => BigDecimal("2#{i}.12"), 288 | :store_cost => BigDecimal("1#{i}.1234"), 289 | :unit_sales => i+1 290 | ) 291 | end 292 | end 293 | 294 | task :create_warehouse_data => :define_models do 295 | puts "==> Creating warehouse data" 296 | Warehouse.delete_all 297 | count = 100 298 | products = Product.order("id").to_a[0...count] 299 | times = TimeDimension.order("id").to_a[0...count] 300 | count.times do |i| 301 | Warehouse.create!( 302 | :product_id => products[i].id, 303 | :time_id => times[i].id, 304 | :units_shipped => i+1, 305 | :store_invoice => BigDecimal("1#{i}.1234") 306 | ) 307 | end 308 | end 309 | 310 | export_data_dir = File.expand_path("spec/support/data") 311 | table_names = %w(time product_classes products customers promotions sales warehouse) 312 | 313 | desc "Export test data" 314 | task :export_data => :create_data do 315 | require "csv" 316 | puts "==> Exporting data" 317 | conn = ActiveRecord::Base.connection 318 | table_names.each do |table_name| 319 | column_names = conn.columns(table_name).map(&:name) 320 | csv_content = conn.select_rows("SELECT #{column_names.join(',')} FROM #{table_name}").map do |row| 321 | row.map do |value| 322 | case value 323 | when Time 324 | value.utc.to_s(:db) 325 | else 326 | value 327 | end 328 | end.to_csv 329 | end.join 330 | file_path = File.expand_path("#{table_name}.csv", export_data_dir) 331 | File.open(file_path, "w") do |file| 332 | file.write column_names.to_csv 333 | file.write csv_content 334 | end 335 | end 336 | end 337 | 338 | task :import_data => :require_spec_helper do 339 | puts "==> Importing data" 340 | conn = ActiveRecord::Base.connection 341 | 342 | case MONDRIAN_DRIVER 343 | when 'vertica' 344 | table_names.each do |table_name| 345 | puts "==> Truncate #{table_name}" 346 | conn.execute "TRUNCATE TABLE #{table_name}" 347 | puts "==> Copy into #{table_name}" 348 | file_path = "#{export_data_dir}/#{table_name}.csv" 349 | columns_string = File.open(file_path) { |f| f.gets }.chomp 350 | count = conn.execute "COPY #{table_name}(#{columns_string}) FROM LOCAL '#{file_path}' " \ 351 | "PARSER public.fcsvparser(header='true') ABORT ON ERROR REJECTMAX 0" 352 | puts "==> Loaded #{count} records" 353 | end 354 | 355 | when 'snowflake' 356 | conn.execute <<-SQL 357 | CREATE OR REPLACE FILE FORMAT csv 358 | TYPE = 'CSV' COMPRESSION = 'AUTO' FIELD_DELIMITER = ',' RECORD_DELIMITER = '\\n' SKIP_HEADER = 1 359 | FIELD_OPTIONALLY_ENCLOSED_BY = '\\042' TRIM_SPACE = FALSE ERROR_ON_COLUMN_COUNT_MISMATCH = TRUE ESCAPE = 'NONE' 360 | ESCAPE_UNENCLOSED_FIELD = 'NONE' DATE_FORMAT = 'AUTO' TIMESTAMP_FORMAT = 'AUTO' NULL_IF = ('') 361 | SQL 362 | conn.execute "CREATE OR REPLACE STAGE csv_stage FILE_FORMAT = csv" 363 | conn.execute "PUT file://#{export_data_dir}/*.csv @csv_stage AUTO_COMPRESS = TRUE" 364 | table_names.each do |table_name| 365 | puts "==> Truncate #{table_name}" 366 | conn.execute "TRUNCATE TABLE #{table_name}" 367 | puts "==> Copy into #{table_name}" 368 | file_path = "#{export_data_dir}/#{table_name}.csv" 369 | columns_string = File.open(file_path) { |f| f.gets }.chomp 370 | count = conn.execute "COPY INTO #{table_name}(#{columns_string}) FROM @csv_stage/#{table_name}.csv.gz " \ 371 | "FILE_FORMAT = (FORMAT_NAME = csv)" 372 | puts "==> Loaded #{count} records" 373 | end 374 | 375 | when 'clickhouse' 376 | table_names.each do |table_name| 377 | puts "==> Truncate #{table_name}" 378 | conn.execute "TRUNCATE TABLE #{table_name}" 379 | puts "==> Copy into #{table_name}" 380 | file_path = "#{export_data_dir}/#{table_name}.csv" 381 | columns_string = File.open(file_path) { |f| f.gets }.chomp 382 | clickhouse_format_class = Java::com.clickhouse.data.ClickHouseFormat rescue Java::com.clickhouse.client.ClickHouseFormat 383 | conn.jdbc_connection.createStatement.write. 384 | query("INSERT INTO #{table_name}(#{columns_string})"). 385 | data(file_path).format(clickhouse_format_class::CSVWithNames).execute 386 | count = conn.select_value("SELECT COUNT(*) FROM #{table_name}").to_i 387 | puts "==> Loaded #{count} records" 388 | end 389 | 390 | when 'mariadb' 391 | table_names.each do |table_name| 392 | puts "==> Truncate #{table_name}" 393 | conn.execute "TRUNCATE TABLE `#{table_name}`" 394 | puts "==> Copy into #{table_name}" 395 | file_path = "#{export_data_dir}/#{table_name}.csv" 396 | columns_string = File.open(file_path) { |f| f.gets }.chomp 397 | count = conn.execute "LOAD DATA LOCAL INFILE '#{file_path}' INTO TABLE `#{table_name}` CHARACTER SET UTF8 " \ 398 | "FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' IGNORE 1 LINES (#{columns_string})" 399 | puts "==> Loaded #{count} records" 400 | end 401 | 402 | end 403 | end 404 | 405 | end 406 | 407 | namespace :spec do 408 | %w(mysql jdbc_mysql postgresql oracle sqlserver vertica snowflake clickhouse mariadb).each do |driver| 409 | desc "Run specs with #{driver} driver" 410 | task driver do 411 | ENV['MONDRIAN_DRIVER'] = driver 412 | Rake::Task['spec'].reenable 413 | Rake::Task['spec'].invoke 414 | end 415 | end 416 | 417 | desc "Run specs with all primary database drivers" 418 | task :all do 419 | %w(mysql jdbc_mysql postgresql oracle sqlserver).each do |driver| 420 | Rake::Task["spec:#{driver}"].invoke 421 | end 422 | end 423 | end 424 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rdoc' 2 | require 'rspec' 3 | require 'logger' 4 | require 'active_record' 5 | require 'activerecord-jdbc-adapter' 6 | require 'pry' 7 | 8 | # Autoload corresponding JDBC driver during require 'jdbc/...' 9 | Java::JavaLang::System.setProperty("jdbc.driver.autoload", "true") 10 | 11 | MONDRIAN_DRIVER = ENV['MONDRIAN_DRIVER'] || 'mysql' 12 | env_prefix = MONDRIAN_DRIVER.upcase 13 | 14 | DATABASE_HOST = ENV["#{env_prefix}_DATABASE_HOST"] || ENV['DATABASE_HOST'] || 'localhost' 15 | DATABASE_PORT = ENV["#{env_prefix}_DATABASE_PORT"] || ENV['DATABASE_PORT'] 16 | DATABASE_PROTOCOL = ENV["#{env_prefix}_DATABASE_PROTOCOL"] || ENV['DATABASE_PROTOCOL'] 17 | DATABASE_USER = ENV["#{env_prefix}_DATABASE_USER"] || ENV['DATABASE_USER'] || 'mondrian_test' 18 | DATABASE_PASSWORD = ENV["#{env_prefix}_DATABASE_PASSWORD"] || ENV['DATABASE_PASSWORD'] || 'mondrian_test' 19 | DATABASE_NAME = ENV["#{env_prefix}_DATABASE_NAME"] || ENV['DATABASE_NAME'] || 'mondrian_test' 20 | DATABASE_INSTANCE = ENV["#{env_prefix}_DATABASE_INSTANCE"] || ENV['DATABASE_INSTANCE'] 21 | 22 | case MONDRIAN_DRIVER 23 | when 'mysql', 'jdbc_mysql' 24 | if jdbc_driver_file = Dir[File.expand_path("mysql*.jar", 'spec/support/jars')].first 25 | require jdbc_driver_file 26 | else 27 | require 'jdbc/mysql' 28 | end 29 | JDBC_DRIVER = (Java::com.mysql.cj.jdbc.Driver rescue nil) ? 'com.mysql.cj.jdbc.Driver' : 'com.mysql.jdbc.Driver' 30 | 31 | when 'postgresql' 32 | require 'jdbc/postgres' 33 | JDBC_DRIVER = 'org.postgresql.Driver' 34 | require 'arjdbc/postgresql' 35 | 36 | when 'oracle' 37 | Dir[File.expand_path("ojdbc*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 38 | require jdbc_driver_file 39 | end 40 | 41 | # PATCH: Fix NameError undefined field 'map' for class 'Java::OrgJruby::RubyObjectSpace::WeakMap' 42 | # pending release of https://github.com/rsim/oracle-enhanced/pull/2360/files 43 | begin 44 | require 'active_record/connection_adapters/oracle_enhanced_adapter' 45 | rescue NameError => e 46 | raise e unless e.message =~ /undefined field 'map'/ 47 | $LOADED_FEATURES << 48 | File.expand_path("active_record/connection_adapters/oracle_enhanced_adapter.rb", $:.grep(/oracle_enhanced/).first) 49 | end 50 | 51 | ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.class_eval do 52 | # Start primary key sequences from 1 (and not 10000) and take just one next value in each session 53 | self.default_sequence_start_value = "1 NOCACHE INCREMENT BY 1" 54 | # PATCH: Restore previous mapping of ActiveRecord datetime to DATE type. 55 | def supports_datetime_with_precision?; false; end 56 | # PATCH: Do not send fractional seconds to DATE type. 57 | def quoted_date(value) 58 | if value.acts_like?(:time) 59 | zone_conversion_method = ActiveRecord::Base.default_timezone == :utc ? :getutc : :getlocal 60 | if value.respond_to?(zone_conversion_method) 61 | value = value.send(zone_conversion_method) 62 | end 63 | end 64 | value.to_s(:db) 65 | end 66 | private 67 | # PATCH: Restore previous mapping of ActiveRecord datetime to DATE type. 68 | const_get(:NATIVE_DATABASE_TYPES)[:datetime] = {name: "DATE"} 69 | alias_method :original_initialize_type_map, :initialize_type_map 70 | def initialize_type_map(m = type_map) 71 | original_initialize_type_map(m) 72 | # PATCH: Map Oracle DATE to DateTime for backwards compatibility 73 | register_class_with_precision m, %r(date)i, ActiveRecord::Type::DateTime 74 | end 75 | end 76 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTestOracle.xml', __FILE__) 77 | 78 | when 'sqlserver' 79 | Dir[File.expand_path("mssql-jdbc*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 80 | require jdbc_driver_file 81 | end 82 | require 'arjdbc/jdbc/adapter' 83 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 84 | def initialize(connection, logger = nil, connection_parameters = nil, config = {}) 85 | super(connection, logger, config.dup) 86 | end 87 | def modify_types(types) 88 | types.merge!( 89 | primary_key: 'bigint NOT NULL IDENTITY(1,1) PRIMARY KEY', 90 | integer: {name: 'int'}, 91 | bigint: {name: 'bigint'}, 92 | boolean: {name: 'bit'}, 93 | decimal: {name: 'decimal'}, 94 | date: {name: 'date'}, 95 | datetime: {name: 'datetime'}, 96 | timestamp: {name: 'datetime'}, 97 | string: {name: 'nvarchar', limit: 4000}, 98 | text: {name: 'nvarchar(max)'} 99 | ) 100 | end 101 | def quote_table_name(name) 102 | name.to_s.split('.').map { |n| quote_column_name(n) }.join('.') 103 | end 104 | def quote_column_name(name) 105 | "[#{name.to_s}]" 106 | end 107 | def columns(table_name, name = nil) 108 | select_all( 109 | "SELECT * FROM information_schema.columns WHERE table_name = #{quote table_name}" 110 | ).map do |column| 111 | ActiveRecord::ConnectionAdapters::Column.new( 112 | column['COLUMN_NAME'], 113 | column['COLUMN_DEFAULT'], 114 | fetch_type_metadata(column['DATA_TYPE']), 115 | column['IS_NULLABLE'] 116 | ) 117 | end 118 | end 119 | def write_query?(sql) 120 | sql =~ /\A(INSERT|UPDATE|DELETE) / 121 | end 122 | end 123 | ::Arel::Visitors::ToSql.class_eval do 124 | private 125 | def visit_Arel_Nodes_Limit(o, collector) 126 | # Do not add LIMIT as it is not supported by MS SQL Server 127 | collector 128 | end 129 | end 130 | require "active_model/type/integer" 131 | ActiveModel::Type::Integer::DEFAULT_LIMIT = 8 132 | JDBC_DRIVER = 'com.microsoft.sqlserver.jdbc.SQLServerDriver' 133 | 134 | when 'vertica' 135 | Dir[File.expand_path("vertica*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 136 | require jdbc_driver_file 137 | end 138 | JDBC_DRIVER = 'com.vertica.jdbc.Driver' 139 | DATABASE_SCHEMA = ENV["#{env_prefix}_DATABASE_SCHEMA"] || ENV['DATABASE_SCHEMA'] || 'mondrian_test' 140 | require 'arjdbc/jdbc/adapter' 141 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 142 | def initialize(connection, logger = nil, connection_parameters = nil, config = {}) 143 | super(connection, logger, config.dup) 144 | end 145 | def modify_types(types) 146 | types[:primary_key] = "int" # Use int instead of identity as data cannot be loaded into identity columns 147 | types[:integer] = "int" 148 | end 149 | def type_to_sql(type, limit: nil, precision: nil, scale: nil, **) 150 | case type.to_sym 151 | when :integer, :primary_key 152 | 'int' # All integers are 64-bit in Vertica and limit should be ignored 153 | else 154 | super 155 | end 156 | end 157 | # By default Vertica stores table and column names in uppercase 158 | def quote_table_name(name) 159 | "\"#{name.to_s}\"" 160 | end 161 | def quote_column_name(name) 162 | "\"#{name.to_s}\"" 163 | end 164 | # exec_insert tries to use Statement.RETURN_GENERATED_KEYS which is not supported by Vertica 165 | def exec_insert(sql, name, binds, pk = nil, sequence_name = nil) 166 | exec_update(sql, name, binds) 167 | end 168 | end 169 | 170 | when 'snowflake' 171 | Dir[File.expand_path("snowflake*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 172 | require jdbc_driver_file 173 | end 174 | JDBC_DRIVER = 'net.snowflake.client.jdbc.SnowflakeDriver' 175 | DATABASE_SCHEMA = ENV["#{env_prefix}_DATABASE_SCHEMA"] || ENV['DATABASE_SCHEMA'] || 'mondrian_test' 176 | WAREHOUSE_NAME = ENV["#{env_prefix}_WAREHOUSE_NAME"] || ENV['WAREHOUSE_NAME'] || 'mondrian_test' 177 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTestOracle.xml', __FILE__) 178 | require 'arjdbc/jdbc/adapter' 179 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 180 | def initialize(connection, logger = nil, connection_parameters = nil, config = {}) 181 | super(connection, logger, config.dup) 182 | end 183 | def modify_types(types) 184 | types[:primary_key] = 'integer' 185 | types[:integer] = 'integer' 186 | end 187 | # exec_insert tries to use Statement.RETURN_GENERATED_KEYS which is not supported by Snowflake 188 | def exec_insert(sql, name, binds, pk = nil, sequence_name = nil) 189 | exec_update(sql, name, binds) 190 | end 191 | end 192 | require 'arjdbc/jdbc/type_converter' 193 | # Hack to disable :text and :binary types for Snowflake 194 | ActiveRecord::ConnectionAdapters::JdbcTypeConverter::AR_TO_JDBC_TYPES.delete(:text) 195 | ActiveRecord::ConnectionAdapters::JdbcTypeConverter::AR_TO_JDBC_TYPES.delete(:binary) 196 | 197 | when 'clickhouse' 198 | Dir[File.expand_path("clickhouse*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 199 | require jdbc_driver_file 200 | end 201 | JDBC_DRIVER = 'com.clickhouse.jdbc.ClickHouseDriver' 202 | DATABASE_SCHEMA = ENV["#{env_prefix}_DATABASE_SCHEMA"] || ENV['DATABASE_SCHEMA'] || 'mondrian_test' 203 | require 'arjdbc/jdbc/adapter' 204 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 205 | def initialize(connection, logger = nil, connection_parameters = nil, config = {}) 206 | super(connection, logger, config.dup) 207 | end 208 | NATIVE_DATABASE_TYPES = { 209 | primary_key: "Int32", # We do not need automatic primary key generation and need to allow inserting PK values 210 | string: {name: "String"}, 211 | text: {name: "String"}, 212 | integer: {name: "Int32"}, 213 | float: {name: "Float64"}, 214 | numeric: {name: "Decimal"}, 215 | decimal: {name: "Decimal"}, 216 | datetime: {name: "DateTime"}, 217 | timestamp: {name: "DateTime"}, 218 | time: {name: "DateTime"}, 219 | date: {name: "Date"}, 220 | binary: {name: "String"}, 221 | boolean: {name: "Boolean"}, 222 | } 223 | def native_database_types 224 | NATIVE_DATABASE_TYPES 225 | end 226 | def modify_types(types) 227 | types[:primary_key] = 'Int32' 228 | types[:integer] = 'Int32' 229 | end 230 | def type_to_sql(type, limit: nil, precision: nil, scale: nil, **) 231 | case type.to_sym 232 | when :integer, :primary_key 233 | return 'Int32' unless limit 234 | case limit.to_i 235 | when 1 then 'Int8' 236 | when 2 then 'Int16' 237 | when 3, 4 then 'Int32' 238 | when 5..8 then 'Int64' 239 | else raise ActiveRecord::ActiveRecordError, 240 | "No integer type has byte size #{limit}. Use a numeric with precision 0 instead." 241 | end 242 | # Ignore limit for string and text 243 | when :string, :text 244 | super(type) 245 | else 246 | super 247 | end 248 | end 249 | def quote_table_name(name) 250 | "`#{name.to_s}`" 251 | end 252 | def quote_column_name(name) 253 | "`#{name.to_s}`" 254 | end 255 | def create_table(name, options = {}) 256 | super(name, {options: "ENGINE=MergeTree ORDER BY tuple()"}.merge(options)) 257 | end 258 | alias_method :exec_update_original, :exec_update 259 | # exec_insert tries to use Statement.RETURN_GENERATED_KEYS which is not supported by ClickHouse 260 | def exec_insert(sql, name, binds, pk = nil, sequence_name = nil) 261 | exec_update_original(sql, name, binds) 262 | end 263 | # Modify UPDATE statements for ClickHouse specific syntax 264 | def exec_update(sql, name, binds) 265 | if sql =~ /\AUPDATE (.*) SET (.*)\z/ 266 | sql = "ALTER TABLE #{$1} UPDATE #{$2}" 267 | end 268 | exec_update_original(sql, name, binds) 269 | end 270 | end 271 | 272 | when 'mariadb' 273 | Dir[File.expand_path("mariadb*.jar", 'spec/support/jars')].each do |jdbc_driver_file| 274 | require jdbc_driver_file 275 | end 276 | JDBC_DRIVER = 'org.mariadb.jdbc.Driver' 277 | require 'arjdbc/jdbc/adapter' 278 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 279 | def initialize(connection, logger = nil, connection_parameters = nil, config = {}) 280 | super(connection, logger, config.dup) 281 | end 282 | def modify_types(types) 283 | types[:primary_key] = "integer" 284 | types[:integer] = "integer" 285 | end 286 | def type_to_sql(type, limit: nil, precision: nil, scale: nil, **) 287 | case type.to_sym 288 | when :integer, :primary_key 289 | return 'integer' unless limit 290 | case limit.to_i 291 | when 1 then 'tinyint' 292 | when 2 then 'smallint' 293 | when 3 then 'mediumint' 294 | when 4 then 'integer' 295 | when 5..8 then 'bigint' 296 | else raise ActiveRecord::ActiveRecordError, 297 | "No integer type has byte size #{limit}. Use a numeric with precision 0 instead." 298 | end 299 | when :text 300 | case limit 301 | when 0..0xff then 'tinytext' 302 | when nil, 0x100..0xffff then 'text' 303 | when 0x10000..0xffffff then'mediumtext' 304 | when 0x1000000..0xffffffff then 'longtext' 305 | else raise ActiveRecordError, "No text type has character length #{limit}" 306 | end 307 | else 308 | super 309 | end 310 | end 311 | def quote_table_name(name) 312 | "`#{name.to_s}`" 313 | end 314 | def quote_column_name(name) 315 | "`#{name.to_s}`" 316 | end 317 | def execute(sql, name = nil, binds = nil) 318 | exec_update(sql, name, binds) 319 | end 320 | def create_table(name, options = {}) 321 | super(name, {options: "ENGINE=Columnstore DEFAULT CHARSET=utf8"}.merge(options)) 322 | end 323 | end 324 | end 325 | 326 | puts "==> Using #{MONDRIAN_DRIVER} driver" 327 | 328 | # Necessary for Aggregate optimizations test 329 | Java::JavaLang::System.setProperty("mondrian.rolap.EnableInMemoryRollup", "false") 330 | 331 | require 'mondrian/olap' 332 | require_relative 'support/matchers/be_like' 333 | 334 | RSpec.configure do |config| 335 | config.include Matchers 336 | config.expect_with(:rspec) { |c| c.syntax = [:should, :expect] } 337 | end 338 | 339 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTest.xml', __FILE__) unless defined?(CATALOG_FILE) 340 | 341 | CONNECTION_PARAMS = if MONDRIAN_DRIVER =~ /^jdbc/ 342 | { 343 | driver: 'jdbc', 344 | jdbc_url: "jdbc:#{MONDRIAN_DRIVER.split('_').last}://#{DATABASE_HOST}/#{DATABASE_NAME}", 345 | jdbc_driver: JDBC_DRIVER, 346 | username: DATABASE_USER, 347 | password: DATABASE_PASSWORD 348 | } 349 | else 350 | { 351 | # Uncomment to test PostgreSQL SSL connection 352 | # properties: {'ssl'=>'true','sslfactory'=>'org.postgresql.ssl.NonValidatingFactory'}, 353 | driver: MONDRIAN_DRIVER, 354 | host: DATABASE_HOST, 355 | port: DATABASE_PORT, 356 | protocol: DATABASE_PROTOCOL.presence, 357 | database: DATABASE_NAME, 358 | username: DATABASE_USER, 359 | password: DATABASE_PASSWORD 360 | }.compact 361 | end 362 | case MONDRIAN_DRIVER 363 | when 'mysql' 364 | CONNECTION_PARAMS[:properties] = {useSSL: false, serverTimezone: 'UTC'} 365 | when 'jdbc_mysql' 366 | CONNECTION_PARAMS[:jdbc_url] << '?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC' 367 | end 368 | 369 | case MONDRIAN_DRIVER 370 | when 'mysql', 'postgresql' 371 | AR_CONNECTION_PARAMS = CONNECTION_PARAMS.slice(:host, :database, :username, :password).merge( 372 | adapter: MONDRIAN_DRIVER, 373 | driver: JDBC_DRIVER, 374 | properties: CONNECTION_PARAMS[:properties].dup || {} 375 | ) 376 | when 'oracle' 377 | AR_CONNECTION_PARAMS = { 378 | adapter: 'oracle_enhanced', 379 | host: CONNECTION_PARAMS[:host], 380 | database: CONNECTION_PARAMS[:database], 381 | username: CONNECTION_PARAMS[:username], 382 | password: CONNECTION_PARAMS[:password], 383 | nls_numeric_characters: '.,' 384 | } 385 | when 'sqlserver' 386 | url = "jdbc:sqlserver://#{CONNECTION_PARAMS[:host]};databaseName=#{CONNECTION_PARAMS[:database]};" 387 | url << ";instanceName=#{DATABASE_INSTANCE}" if DATABASE_INSTANCE 388 | AR_CONNECTION_PARAMS = { 389 | adapter: 'jdbc', 390 | driver: JDBC_DRIVER, 391 | url: url, 392 | username: CONNECTION_PARAMS[:username], 393 | password: CONNECTION_PARAMS[:password], 394 | connection_alive_sql: 'SELECT 1', 395 | sqlserver_version: ENV['SQLSERVER_VERSION'], 396 | dialect: 'jdbc' 397 | } 398 | when 'vertica' 399 | CONNECTION_PARAMS[:properties] = { 400 | 'SearchPath' => DATABASE_SCHEMA 401 | } 402 | AR_CONNECTION_PARAMS = { 403 | adapter: 'jdbc', 404 | driver: JDBC_DRIVER, 405 | url: "jdbc:#{MONDRIAN_DRIVER}://#{CONNECTION_PARAMS[:host]}/#{CONNECTION_PARAMS[:database]}" \ 406 | "?SearchPath=#{DATABASE_SCHEMA}", # &LogLevel=DEBUG 407 | username: CONNECTION_PARAMS[:username], 408 | password: CONNECTION_PARAMS[:password], 409 | dialect: 'jdbc' 410 | } 411 | when 'snowflake' 412 | CONNECTION_PARAMS[:database_schema] = DATABASE_SCHEMA 413 | CONNECTION_PARAMS[:warehouse] = WAREHOUSE_NAME 414 | CONNECTION_PARAMS[:properties] = { 415 | # 'tracing' => 'ALL' 416 | } 417 | AR_CONNECTION_PARAMS = { 418 | adapter: 'jdbc', 419 | driver: JDBC_DRIVER, 420 | url: "jdbc:#{MONDRIAN_DRIVER}://#{CONNECTION_PARAMS[:host]}/?db=#{CONNECTION_PARAMS[:database]}" \ 421 | "&schema=#{DATABASE_SCHEMA}&warehouse=#{WAREHOUSE_NAME}", # &tracing=ALL 422 | username: CONNECTION_PARAMS[:username], 423 | password: CONNECTION_PARAMS[:password], 424 | dialect: 'jdbc' 425 | } 426 | when 'clickhouse' 427 | # CREATE USER mondrian_test IDENTIFIED WITH plaintext_password BY 'mondrian_test'; 428 | # CREATE DATABASE mondrian_test; 429 | # GRANT ALL ON mondrian_test.* TO mondrian_test; 430 | 431 | # For testing different protocols 432 | # CONNECTION_PARAMS[:protocol] = 'http' 433 | # CONNECTION_PARAMS[:properties] ={'http_connection_provider' => 'APACHE_HTTP_CLIENT'} 434 | 435 | AR_CONNECTION_PARAMS = { 436 | adapter: 'jdbc', 437 | driver: JDBC_DRIVER, 438 | url: "jdbc:ch:#{CONNECTION_PARAMS[:protocol]&.+(':')}//#{CONNECTION_PARAMS[:host]}/#{CONNECTION_PARAMS[:database]}", 439 | username: CONNECTION_PARAMS[:username], 440 | password: CONNECTION_PARAMS[:password], 441 | dialect: 'jdbc' 442 | } 443 | when /jdbc/ 444 | AR_CONNECTION_PARAMS = { 445 | adapter: MONDRIAN_DRIVER =~ /mysql/ ? 'mysql' : 'jdbc', 446 | driver: JDBC_DRIVER, 447 | url: CONNECTION_PARAMS[:jdbc_url], 448 | username: CONNECTION_PARAMS[:username], 449 | password: CONNECTION_PARAMS[:password], 450 | dialect: MONDRIAN_DRIVER =~ /mysql/ ? 'mysql' : 'jdbc' 451 | } 452 | else 453 | AR_CONNECTION_PARAMS = { 454 | adapter: 'jdbc', 455 | driver: JDBC_DRIVER, 456 | url: "jdbc:#{MONDRIAN_DRIVER}://#{CONNECTION_PARAMS[:host]}" + 457 | (CONNECTION_PARAMS[:port] ? ":#{CONNECTION_PARAMS[:port]}" : "") + "/#{CONNECTION_PARAMS[:database]}", 458 | username: CONNECTION_PARAMS[:username], 459 | password: CONNECTION_PARAMS[:password], 460 | dialect: 'jdbc' 461 | } 462 | end 463 | 464 | CONNECTION_PARAMS_WITH_CATALOG = CONNECTION_PARAMS.merge(catalog: CATALOG_FILE) 465 | 466 | ActiveRecord::Base.establish_connection(AR_CONNECTION_PARAMS) 467 | -------------------------------------------------------------------------------- /spec/support/data/.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | -------------------------------------------------------------------------------- /spec/support/jars/.gitignore: -------------------------------------------------------------------------------- 1 | mysql*.jar 2 | mssql-jdbc*.jar 3 | ojdbc*.jar 4 | vertica-jdbc*.jar 5 | snowflake-jdbc*.jar 6 | clickhouse*.jar 7 | mariadb*.jar 8 | -------------------------------------------------------------------------------- /spec/support/matchers/be_like.rb: -------------------------------------------------------------------------------- 1 | module Matchers 2 | class BeLike 3 | def initialize(expected) 4 | @expected = expected.gsub(/>\s*\n\s*/, '> ').gsub(/\s+/, ' ').strip 5 | end 6 | 7 | def matches?(actual) 8 | @actual = actual.gsub(/>\s*\n\s*/, '> ').gsub(/\s+/, ' ').strip 9 | @expected == @actual 10 | end 11 | 12 | def failure_message 13 | "expected\n#{@actual}\nto be like\n#{@expected}" 14 | end 15 | 16 | def negative_failure_message 17 | "expected\n#{@actual}\nto be unlike\n#{@expected}" 18 | end 19 | alias_method :failure_message_when_negated, :negative_failure_message 20 | end 21 | 22 | def be_like(expected) 23 | BeLike.new(expected) 24 | end 25 | end 26 | --------------------------------------------------------------------------------