├── VERSION ├── .rspec ├── lib ├── mondrian-olap.rb └── mondrian │ ├── jars │ ├── olap4j.jar │ ├── javacup.jar │ ├── mondrian.jar │ ├── log4j-1.2.8.jar │ ├── eigenbase-xom.jar │ ├── commons-math-1.0.jar │ ├── commons-pool-1.2.jar │ ├── commons-vfs-1.0.jar │ ├── eigenbase-resgen.jar │ ├── commons-dbcp-1.2.1.jar │ ├── commons-logging-1.0.4.jar │ ├── eigenbase-properties.jar │ ├── commons-collections-3.1.jar │ └── log4j.properties │ ├── olap.rb │ └── olap │ ├── error.rb │ ├── schema_element.rb │ ├── connection.rb │ ├── cube.rb │ ├── result.rb │ ├── schema_udf.rb │ ├── query.rb │ └── schema.rb ├── .gitignore ├── Gemfile ├── spec ├── support │ └── matchers │ │ └── be_like.rb ├── connection_spec.rb ├── mondrian_spec.rb ├── spec_helper.rb ├── connection_role_spec.rb ├── fixtures │ ├── MondrianTestOracle.xml │ └── MondrianTest.xml ├── rake_tasks.rb ├── cube_spec.rb └── query_spec.rb ├── LICENSE.txt ├── Rakefile ├── Changelog.md ├── RUNNING_TESTS.rdoc ├── mondrian-olap.gemspec ├── LICENSE-Mondrian.html └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.4.0 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --backtrace 3 | -------------------------------------------------------------------------------- /lib/mondrian-olap.rb: -------------------------------------------------------------------------------- 1 | require 'mondrian/olap' 2 | -------------------------------------------------------------------------------- /lib/mondrian/jars/olap4j.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/olap4j.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/javacup.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/javacup.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/mondrian.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/mondrian.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/log4j-1.2.8.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/log4j-1.2.8.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-xom.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/eigenbase-xom.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-math-1.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-math-1.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-pool-1.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-pool-1.2.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-vfs-1.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-vfs-1.0.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-resgen.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/eigenbase-resgen.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-dbcp-1.2.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-dbcp-1.2.1.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-logging-1.0.4.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-logging-1.0.4.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/eigenbase-properties.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/eigenbase-properties.jar -------------------------------------------------------------------------------- /lib/mondrian/jars/commons-collections-3.1.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/mondrian-olap/master/lib/mondrian/jars/commons-collections-3.1.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .rvmrc 3 | .DS_Store 4 | .autotest 5 | coverage 6 | doc 7 | pkg 8 | log 9 | tmp 10 | sqlnet.log 11 | Gemfile.lock 12 | .project 13 | -------------------------------------------------------------------------------- /lib/mondrian/jars/log4j.properties: -------------------------------------------------------------------------------- 1 | # Logs errors on the console 2 | # 3 | log4j.rootLogger = ERROR, A1 4 | log4j.appender.A1 = org.apache.log4j.ConsoleAppender 5 | log4j.appender.A1.layout=org.apache.log4j.PatternLayout 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'nokogiri', '~> 1.5.0' 4 | 5 | group :development do 6 | gem 'jruby-openssl' 7 | gem 'jeweler', '~> 1.8.3' 8 | gem 'rdoc' 9 | gem 'rspec', '~> 2.10' 10 | gem 'autotest' 11 | gem 'jdbc-mysql' 12 | gem 'jdbc-postgres' 13 | gem 'jdbc-luciddb' 14 | gem 'jdbc-jtds' 15 | gem 'activerecord', '= 3.2.8' 16 | gem 'activerecord-jdbc-adapter', '= 1.2.2' 17 | gem 'activerecord-oracle_enhanced-adapter', :require => false 18 | gem 'coffee-script', '~> 2.2.0' 19 | gem 'therubyrhino', '~> 1.73.1' 20 | gem 'pry', :require => false 21 | end 22 | -------------------------------------------------------------------------------- /lib/mondrian/olap.rb: -------------------------------------------------------------------------------- 1 | require 'java' 2 | 3 | directory = File.expand_path("../jars", __FILE__) 4 | Dir["#{directory}/*.jar"].each do |file| 5 | require file 6 | end 7 | 8 | unless java.lang.System.getProperty("log4j.configuration") 9 | file_uri = java.io.File.new("#{directory}/log4j.properties").toURI.to_s 10 | java.lang.System.setProperty("log4j.configuration", file_uri) 11 | end 12 | # register Mondrian olap4j driver 13 | Java::mondrian.olap4j.MondrianOlap4jDriver 14 | Java::mondrian.rolap.RolapUtil 15 | 16 | %w(error connection query result schema schema_udf cube).each do |file| 17 | require "mondrian/olap/#{file}" 18 | end 19 | -------------------------------------------------------------------------------- /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 | end 20 | 21 | def be_like(expected) 22 | BeLike.new(expected) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2010-2012 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | 11 | require 'rake' 12 | 13 | require 'jeweler' 14 | Jeweler::Tasks.new do |gem| 15 | gem.name = "mondrian-olap" 16 | gem.summary = "JRuby API for Mondrian OLAP Java library" 17 | gem.description = <<-EOS 18 | JRuby gem for performing multidimensional queries of relational database data using Mondrian OLAP Java library 19 | EOS 20 | gem.email = "raimonds.simanovskis@gmail.com" 21 | gem.homepage = "http://github.com/rsim/mondrian-olap" 22 | gem.authors = ["Raimonds Simanovskis"] 23 | gem.platform = "java" 24 | gem.extra_rdoc_files = ['README.md'] 25 | end 26 | Jeweler::RubygemsDotOrgTasks.new 27 | 28 | require 'rspec/core/rake_task' 29 | RSpec::Core::RakeTask.new(:spec) 30 | 31 | RSpec::Core::RakeTask.new(:rcov) do |t| 32 | t.rcov = true 33 | t.rcov_opts = ['--exclude', '/Library,spec/'] 34 | end 35 | 36 | task :default => :spec 37 | 38 | require 'rdoc/task' 39 | RDoc::Task.new do |rdoc| 40 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 41 | 42 | rdoc.rdoc_dir = 'doc' 43 | rdoc.title = "mondrian-olap #{version}" 44 | rdoc.rdoc_files.include('README*') 45 | rdoc.rdoc_files.include('lib/**/*.rb') 46 | end 47 | 48 | require 'spec/rake_tasks' 49 | -------------------------------------------------------------------------------- /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 10 | 11 | def initialize(native_error) 12 | @native_error = native_error 13 | get_root_cause 14 | super(native_error.message) 15 | add_root_cause_to_backtrace 16 | end 17 | 18 | def self.wrap_native_exception 19 | yield 20 | rescue NativeException => e 21 | if e.message =~ NATIVE_ERROR_REGEXP 22 | raise Mondrian::OLAP::Error.new(e) 23 | else 24 | raise 25 | end 26 | end 27 | 28 | private 29 | 30 | def get_root_cause 31 | @root_cause = nil 32 | e = @native_error 33 | while e.respond_to?(:cause) && (cause = e.cause) 34 | @root_cause = e = cause 35 | end 36 | message = e.message 37 | if message =~ /\AMondrian Error:(.*)\Z/m 38 | message = $1 39 | end 40 | @root_cause_message = message 41 | end 42 | 43 | def add_root_cause_to_backtrace 44 | bt = @native_error.backtrace 45 | if @root_cause 46 | root_cause_bt = Array(@root_cause.backtrace) 47 | root_cause_bt[0,5].reverse.each do |bt_line| 48 | bt.unshift "root cause: #{bt_line}" 49 | end 50 | bt.unshift "root cause: #{@root_cause.java_class.name}: #{@root_cause.message.chomp}" 51 | end 52 | set_backtrace bt 53 | end 54 | 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /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 be_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 be_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 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 'luciddb' then 'mondrian.spi.impl.LucidDbDialect' 53 | when 'mssql' then 'mondrian.spi.impl.MicrosoftSqlServerDialect' 54 | when 'sqlserver' then 'mondrian.spi.impl.MicrosoftSqlServerDialect' 55 | end 56 | end 57 | 58 | end 59 | 60 | describe "close" do 61 | before(:all) do 62 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG) 63 | end 64 | 65 | it "should not be connected after close" do 66 | @olap.close 67 | @olap.should_not be_connected 68 | end 69 | 70 | end 71 | 72 | end -------------------------------------------------------------------------------- /spec/mondrian_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Mondrian features" do 4 | before(:all) do 5 | @schema = Mondrian::OLAP::Schema.define do 6 | cube 'Sales' do 7 | table 'sales' 8 | dimension 'Gender', :foreign_key => 'customer_id' do 9 | hierarchy :has_all => true, :primary_key => 'id' do 10 | table 'customers' 11 | level 'Gender', :column => 'gender', :unique_members => true 12 | end 13 | end 14 | dimension 'Customers', :foreign_key => 'customer_id' do 15 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id' do 16 | table 'customers' 17 | level 'Country', :column => 'country', :unique_members => true 18 | level 'State Province', :column => 'state_province', :unique_members => true 19 | level 'City', :column => 'city', :unique_members => false 20 | level 'Name', :column => 'fullname', :unique_members => true 21 | end 22 | end 23 | dimension 'Time', :foreign_key => 'time_id', :type => 'TimeDimension' do 24 | hierarchy :has_all => false, :primary_key => 'id' do 25 | table 'time' 26 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 27 | level 'Quarter', :column => 'quarter', :unique_members => false, :level_type => 'TimeQuarters' 28 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeMonths' 29 | end 30 | hierarchy 'Weekly', :has_all => false, :primary_key => 'id' do 31 | table 'time' 32 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 33 | level 'Week', :column => 'weak_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeWeeks' 34 | end 35 | end 36 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum' 37 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 38 | end 39 | end 40 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 41 | end 42 | 43 | # test for http://jira.pentaho.com/browse/MONDRIAN-1050 44 | it "should order rows by DateTime expression" do 45 | lambda do 46 | @olap.from('Sales'). 47 | columns('[Measures].[Unit Sales]'). 48 | rows('[Customers].children').order('Now()', :asc). 49 | execute 50 | end.should_not raise_error 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | ### 0.4.0 / 2012-12-03 2 | 3 | * New features 4 | * upgraded to latest Mondrian 3.5 version (build from 2012-11-29) 5 | as well as corresponding olap4j 1.0.1 version 6 | * support for JRuby 1.7 and Java 7 VM 7 | * user defined functions and formatters in JavaScript, CoffeeScript and Ruby 8 | * shared user defined functions in Ruby 9 | * all exceptions are wrapped in Mondrian::OLAP::Error exception with root_cause_message method 10 | * drill through from result cell to source measure and dimension table rows 11 | * support for Mondrian schema roles to limit cube data access 12 | * Improvements 13 | * get description of cube, dimension, hierarchy and level from schema definition 14 | * visible? method for measures and calculated members 15 | * nonempty_crossjoin query builder method 16 | * schema definition with nested table joins 17 | * added approx_row_count schema level attribute 18 | 19 | ### 0.3.0 / 2011-11-12 20 | 21 | * New features 22 | * upgraded to Mondrian 3.3.0 version (latest shapshot with additional bug fixes) 23 | as well as corresponding olap4j 1.0.0 version 24 | * support for SQL Server (jTDS and Microsoft drivers) 25 | * aggregates definition in schema 26 | * possibility to include XML fragments in schema 27 | (e.g. to paste XML for aggregates that is generated by Mondrian aggregation designer) 28 | * define level properties in schema 29 | * `sql` element for `table` element in schema 30 | (to define custom WHERE conditions for dimensions or fact table) 31 | * `view` element in schema 32 | (to define custom SQL SELECT statements instead of existing table) 33 | * `measure_expression` element in schema 34 | (to define custom SQL expression for measure instead of fact column) 35 | * allow crossjoin of where conditions as well as where with several same dimension members 36 | * Improvements 37 | * use latest Nokogiri 1.5.0 version 38 | 39 | ### 0.2.0 / 2011-07-01 40 | 41 | * New features 42 | * support for LucidDB database 43 | * Improvements 44 | * only set log4j configuration file if not set already (possible to override e.g. Mondrian debugging settings) 45 | * `result.to_html(:formatted=>true)` will return formatted results 46 | * set Unicode encoding for mysql connection 47 | * `format_string` attribute and `formula` element for calculated members 48 | * `:use_content_checksum` connection option (by default set to true) 49 | * `key_expression`, `name_expression`, `ordinal_expression` elements with `sql` subelement support for levels 50 | * `:upcase_data_dictionary` option for schema definition 51 | * Bug fixes 52 | * fixed examples in README 53 | * correctly quote `CatalogContent` in connection string (to allow usage of semicolons in generated XML catalog) 54 | * workarounds for issues with Java classloader when using in production mode with jruby-rack 55 | * construct correct file path on Windows 56 | 57 | ### 0.1.0 / 2011-03-18 58 | 59 | * Initial release 60 | * support for MySQL, PostgreSQL and Oracle databases 61 | -------------------------------------------------------------------------------- /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", "luciddb", "mssql" (jTDS) or "sqlserver" (Microsoft JDBC) to specify database driver that should be used. 4 | 5 | If using MySQL, PostgreSQL or 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 LucidDB database then create schema MONDRIAN_TEST and create user MONDRIAN_TEST with password mondrian_test and with default schema MONDRIAN_TEST. By default it is assumed that database is located on localhost (can be overridden with DATABASE_HOST environment variable). 10 | 11 | See spec/spec_helper.rb for details of default connection parameters and how to override them. 12 | 13 | == Creating test data 14 | 15 | Install necessary gems with 16 | 17 | bundle install 18 | 19 | Create tables with test data using 20 | 21 | rake db:create_data 22 | 23 | or specify which database driver to use 24 | 25 | rake db:create_data MONDRIAN_DRIVER=mysql 26 | rake db:create_data MONDRIAN_DRIVER=postgresql 27 | rake db:create_data MONDRIAN_DRIVER=oracle 28 | rake db:create_data MONDRIAN_DRIVER=mssql 29 | rake db:create_data MONDRIAN_DRIVER=sqlserver 30 | 31 | In case of LucidDB data are not generated and inserted directly into database but are imported from MySQL mondrian_test database (because inserting individual records into LucidDB is very inefficient). Therefore at first generate test data with mysql (using default database settings) and then run data creation task for LucidDB. 32 | 33 | rake db:create_data MONDRIAN_DRIVER=mysql 34 | rake db:create_data MONDRIAN_DRIVER=luciddb 35 | 36 | == Running tests 37 | 38 | Run tests with 39 | 40 | rake spec 41 | 42 | or specify which database driver to use 43 | 44 | rake spec MONDRIAN_DRIVER=mysql 45 | rake spec MONDRIAN_DRIVER=postgresql 46 | rake spec MONDRIAN_DRIVER=oracle 47 | rake spec MONDRIAN_DRIVER=luciddb 48 | rake spec MONDRIAN_DRIVER=mssql 49 | rake spec MONDRIAN_DRIVER=sqlserver 50 | 51 | or also alternatively with 52 | 53 | rake spec:mysql 54 | rake spec:postgresql 55 | rake spec:oracle 56 | rake spec:luciddb 57 | rake spec:mssql 58 | rake spec:sqlserver 59 | 60 | You can also run all tests on all databases with 61 | 62 | rake spec:all 63 | 64 | == JRuby versions 65 | 66 | 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 1.6 and 1.7 on Java 6 and 7. 67 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler" 3 | Bundler.setup(:default, :development) 4 | 5 | $:.unshift(File.dirname(__FILE__) + '/../lib') 6 | 7 | require 'rspec' 8 | require 'active_record' 9 | 10 | DATABASE_HOST = ENV['DATABASE_HOST'] || 'localhost' 11 | DATABASE_USER = ENV['DATABASE_USER'] || 'mondrian_test' 12 | DATABASE_PASSWORD = ENV['DATABASE_PASSWORD'] || 'mondrian_test' 13 | DATABASE_NAME = ENV['DATABASE_NAME'] || 'mondrian_test' 14 | DATABASE_INSTANCE = ENV['DATABASE_INSTANCE'] 15 | MONDRIAN_DRIVER = ENV['MONDRIAN_DRIVER'] || 'mysql' 16 | 17 | case MONDRIAN_DRIVER 18 | when 'mysql' 19 | require 'jdbc/mysql' 20 | JDBC_DRIVER = 'com.mysql.jdbc.Driver' 21 | when 'postgresql' 22 | require 'jdbc/postgres' 23 | JDBC_DRIVER = 'org.postgresql.Driver' 24 | when 'oracle' 25 | require 'active_record/connection_adapters/oracle_enhanced_adapter' 26 | DATABASE_NAME = ENV['DATABASE_NAME'] || 'orcl' 27 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTestOracle.xml', __FILE__) 28 | when 'mssql' 29 | require 'jdbc/jtds' 30 | JDBC_DRIVER = 'net.sourceforge.jtds.jdbc.Driver' 31 | when 'sqlserver' 32 | JDBC_DRIVER = 'com.microsoft.sqlserver.jdbc.SQLServerDriver' 33 | when 'luciddb' 34 | require 'jdbc/luciddb' 35 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTestOracle.xml', __FILE__) 36 | 37 | # Hack to disable :text type for LucidDB 38 | require 'arjdbc/jdbc/type_converter' 39 | ActiveRecord::ConnectionAdapters::JdbcTypeConverter::AR_TO_JDBC_TYPES.delete(:text) 40 | 41 | # patches for LucidDB minimal AR support 42 | require 'arjdbc/jdbc/adapter' 43 | ActiveRecord::ConnectionAdapters::JdbcAdapter.class_eval do 44 | def modify_types(tp) 45 | # mapping of ActiveRecord data types to LucidDB data types 46 | # data will be imported into LucidDB therefore primary key is defined as simple integer field 47 | tp[:primary_key] = "INT" 48 | tp[:integer] = "INT" 49 | end 50 | # by default LucidDB stores table and column names in uppercase 51 | def quote_table_name(name) 52 | "\"#{name.to_s.upcase}\"" 53 | end 54 | def quote_column_name(name) 55 | "\"#{name.to_s.upcase}\"" 56 | end 57 | end 58 | JDBC_DRIVER = 'org.luciddb.jdbc.LucidDbClientDriver' 59 | DATABASE_USER.upcase! if DATABASE_USER == 'mondrian_test' 60 | DATABASE_NAME = nil 61 | DATABASE_SCHEMA = ENV['DATABASE_SCHEMA'] || 'mondrian_test' 62 | end 63 | 64 | puts "==> Using #{MONDRIAN_DRIVER} driver" 65 | 66 | require 'mondrian/olap' 67 | require 'support/matchers/be_like' 68 | 69 | RSpec.configure do |config| 70 | config.include Matchers 71 | end 72 | 73 | CATALOG_FILE = File.expand_path('../fixtures/MondrianTest.xml', __FILE__) unless defined?(CATALOG_FILE) 74 | 75 | CONNECTION_PARAMS = { 76 | :driver => MONDRIAN_DRIVER, 77 | :host => DATABASE_HOST, 78 | :database => DATABASE_NAME, 79 | :username => DATABASE_USER, 80 | :password => DATABASE_PASSWORD 81 | } 82 | 83 | case MONDRIAN_DRIVER 84 | when 'oracle' 85 | AR_CONNECTION_PARAMS = { 86 | :adapter => 'oracle_enhanced', 87 | :host => CONNECTION_PARAMS[:host], 88 | :database => CONNECTION_PARAMS[:database], 89 | :username => CONNECTION_PARAMS[:username], 90 | :password => CONNECTION_PARAMS[:password] 91 | } 92 | when 'luciddb' 93 | CONNECTION_PARAMS[:database] = nil 94 | CONNECTION_PARAMS[:database_schema] = DATABASE_SCHEMA 95 | AR_CONNECTION_PARAMS = { 96 | :adapter => 'jdbc', 97 | :driver => JDBC_DRIVER, 98 | :url => "jdbc:#{MONDRIAN_DRIVER}:http://#{CONNECTION_PARAMS[:host]};schema=#{CONNECTION_PARAMS[:database_schema]}", 99 | :username => CONNECTION_PARAMS[:username], 100 | :password => CONNECTION_PARAMS[:password] 101 | } 102 | when 'mssql' 103 | url = "jdbc:jtds:sqlserver://#{CONNECTION_PARAMS[:host]}/#{CONNECTION_PARAMS[:database]}" 104 | url << ";instance=#{DATABASE_INSTANCE}" if DATABASE_INSTANCE 105 | AR_CONNECTION_PARAMS = { 106 | :adapter => 'jdbc', 107 | :driver => JDBC_DRIVER, 108 | :url => url, 109 | :username => CONNECTION_PARAMS[:username], 110 | :password => CONNECTION_PARAMS[:password] 111 | } 112 | when 'sqlserver' 113 | url = "jdbc:sqlserver://#{CONNECTION_PARAMS[:host]};databaseName=#{CONNECTION_PARAMS[:database]};" 114 | url << ";instanceName=#{DATABASE_INSTANCE}" if DATABASE_INSTANCE 115 | AR_CONNECTION_PARAMS = { 116 | :adapter => 'jdbc', 117 | :driver => JDBC_DRIVER, 118 | :url => url, 119 | :username => CONNECTION_PARAMS[:username], 120 | :password => CONNECTION_PARAMS[:password] 121 | } 122 | else 123 | AR_CONNECTION_PARAMS = { 124 | :adapter => 'jdbc', 125 | :driver => JDBC_DRIVER, 126 | :url => "jdbc:#{MONDRIAN_DRIVER}://#{CONNECTION_PARAMS[:host]}/#{CONNECTION_PARAMS[:database]}", 127 | :username => CONNECTION_PARAMS[:username], 128 | :password => CONNECTION_PARAMS[:password] 129 | } 130 | end 131 | 132 | CONNECTION_PARAMS_WITH_CATALOG = CONNECTION_PARAMS.merge(:catalog => CATALOG_FILE) 133 | 134 | ActiveRecord::Base.establish_connection(AR_CONNECTION_PARAMS) 135 | -------------------------------------------------------------------------------- /lib/mondrian/olap/schema_element.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | 3 | module Mondrian 4 | module OLAP 5 | class SchemaElement 6 | def initialize(name = nil, attributes = {}, &block) 7 | # if just attributes hash provided 8 | if name.is_a?(Hash) && attributes == {} 9 | attributes = name 10 | name = nil 11 | end 12 | @attributes = {} 13 | if name 14 | if self.class.content 15 | @content = name 16 | else 17 | @attributes[:name] = name 18 | end 19 | end 20 | @attributes.merge!(attributes) 21 | self.class.elements.each do |element| 22 | instance_variable_set("@#{pluralize(element)}", []) 23 | end 24 | @xml_fragments = [] 25 | instance_eval(&block) if block 26 | end 27 | 28 | def self.attributes(*names) 29 | names.each do |name| 30 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 31 | def #{name}(*args) 32 | if args.empty? 33 | @attributes[:#{name}] 34 | elsif args.size == 1 35 | @attributes[:#{name}] = args[0] 36 | else 37 | raise ArgumentError, "too many arguments" 38 | end 39 | end 40 | RUBY 41 | end 42 | end 43 | 44 | def self.data_dictionary_names(*names) 45 | return @data_dictionary_names || [] if names.empty? 46 | @data_dictionary_names ||= [] 47 | @data_dictionary_names.concat(names) 48 | end 49 | 50 | def self.elements(*names) 51 | return @elements || [] if names.empty? 52 | 53 | @elements ||= [] 54 | @elements.concat(names) 55 | 56 | names.each do |name| 57 | next if name == :xml 58 | attr_reader pluralize(name).to_sym 59 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 60 | def #{name}(name=nil, attributes = {}, &block) 61 | @#{pluralize(name)} << Schema::#{camel_case(name)}.new(name, attributes, &block) 62 | end 63 | RUBY 64 | end 65 | end 66 | 67 | def self.content(type=nil) 68 | return @content if type.nil? 69 | @content = type 70 | end 71 | 72 | attr_reader :xml_fragments 73 | def xml(string) 74 | string = string.strip 75 | fragment = Nokogiri::XML::DocumentFragment.parse(string) 76 | raise ArgumentError, "Invalid XML fragment:\n#{string}" if fragment.children.empty? 77 | @xml_fragments << string 78 | end 79 | 80 | def to_xml(options={}) 81 | options[:upcase_data_dictionary] = @upcase_data_dictionary unless @upcase_data_dictionary.nil? 82 | Nokogiri::XML::Builder.new do |xml| 83 | add_to_xml(xml, options) 84 | end.to_xml 85 | end 86 | 87 | protected 88 | 89 | def add_to_xml(xml, options) 90 | if self.class.content 91 | xml.send(tag_name(self.class.name), @content, xmlized_attributes(options)) 92 | else 93 | xml.send(tag_name(self.class.name), xmlized_attributes(options)) do 94 | xml_fragments_added = false 95 | self.class.elements.each do |element| 96 | if element == :xml 97 | add_xml_fragments(xml) 98 | xml_fragments_added = true 99 | else 100 | instance_variable_get("@#{pluralize(element)}").each {|item| item.add_to_xml(xml, options)} 101 | end 102 | end 103 | add_xml_fragments(xml) unless xml_fragments_added 104 | end 105 | end 106 | end 107 | 108 | def add_xml_fragments(xml) 109 | @xml_fragments.each do |xml_fragment| 110 | xml.send(:insert, Nokogiri::XML::DocumentFragment.parse(xml_fragment)) 111 | end 112 | end 113 | 114 | private 115 | 116 | def xmlized_attributes(options) 117 | # data dictionary values should be in uppercase if schema defined with :upcase_data_dictionary => true 118 | # or by default when using Oracle or LucidDB driver (can be overridden by :upcase_data_dictionary => false) 119 | upcase_attributes = if options[:upcase_data_dictionary].nil? && %w(oracle luciddb).include?(options[:driver]) || 120 | options[:upcase_data_dictionary] 121 | self.class.data_dictionary_names 122 | else 123 | [] 124 | end 125 | hash = {} 126 | @attributes.each do |attr, value| 127 | value = value.upcase if upcase_attributes.include?(attr) 128 | hash[ 129 | # camelcase attribute name 130 | attr.to_s.gsub(/_([^_]+)/){|m| $1.capitalize} 131 | ] = value 132 | end 133 | hash 134 | end 135 | 136 | def self.pluralize(string) 137 | string = string.to_s 138 | case string 139 | when /^(.*)y$/ 140 | "#{$1}ies" 141 | else 142 | "#{string}s" 143 | end 144 | end 145 | 146 | def pluralize(string) 147 | self.class.pluralize(string) 148 | end 149 | 150 | def self.camel_case(string) 151 | string.to_s.split('_').map{|s| s.capitalize}.join('') 152 | end 153 | 154 | def camel_case(string) 155 | self.class.camel_case(string) 156 | end 157 | 158 | def tag_name(string) 159 | string.split('::').last << '_' 160 | end 161 | end 162 | 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /mondrian-olap.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "mondrian-olap" 8 | s.version = "0.4.0" 9 | s.platform = "java" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 12 | s.authors = ["Raimonds Simanovskis"] 13 | s.date = "2012-12-03" 14 | s.description = "JRuby gem for performing multidimensional queries of relational database data using Mondrian OLAP Java library\n" 15 | s.email = "raimonds.simanovskis@gmail.com" 16 | s.extra_rdoc_files = [ 17 | "README.md" 18 | ] 19 | s.files = [ 20 | ".rspec", 21 | "Changelog.md", 22 | "Gemfile", 23 | "LICENSE-Mondrian.html", 24 | "LICENSE.txt", 25 | "README.md", 26 | "RUNNING_TESTS.rdoc", 27 | "Rakefile", 28 | "VERSION", 29 | "lib/mondrian-olap.rb", 30 | "lib/mondrian/jars/commons-collections-3.1.jar", 31 | "lib/mondrian/jars/commons-dbcp-1.2.1.jar", 32 | "lib/mondrian/jars/commons-logging-1.0.4.jar", 33 | "lib/mondrian/jars/commons-math-1.0.jar", 34 | "lib/mondrian/jars/commons-pool-1.2.jar", 35 | "lib/mondrian/jars/commons-vfs-1.0.jar", 36 | "lib/mondrian/jars/eigenbase-properties.jar", 37 | "lib/mondrian/jars/eigenbase-resgen.jar", 38 | "lib/mondrian/jars/eigenbase-xom.jar", 39 | "lib/mondrian/jars/javacup.jar", 40 | "lib/mondrian/jars/log4j-1.2.8.jar", 41 | "lib/mondrian/jars/log4j.properties", 42 | "lib/mondrian/jars/mondrian.jar", 43 | "lib/mondrian/jars/olap4j.jar", 44 | "lib/mondrian/olap.rb", 45 | "lib/mondrian/olap/connection.rb", 46 | "lib/mondrian/olap/cube.rb", 47 | "lib/mondrian/olap/error.rb", 48 | "lib/mondrian/olap/query.rb", 49 | "lib/mondrian/olap/result.rb", 50 | "lib/mondrian/olap/schema.rb", 51 | "lib/mondrian/olap/schema_element.rb", 52 | "lib/mondrian/olap/schema_udf.rb", 53 | "mondrian-olap.gemspec", 54 | "spec/connection_role_spec.rb", 55 | "spec/connection_spec.rb", 56 | "spec/cube_spec.rb", 57 | "spec/fixtures/MondrianTest.xml", 58 | "spec/fixtures/MondrianTestOracle.xml", 59 | "spec/mondrian_spec.rb", 60 | "spec/query_spec.rb", 61 | "spec/rake_tasks.rb", 62 | "spec/schema_definition_spec.rb", 63 | "spec/spec_helper.rb", 64 | "spec/support/matchers/be_like.rb" 65 | ] 66 | s.homepage = "http://github.com/rsim/mondrian-olap" 67 | s.require_paths = ["lib"] 68 | s.rubygems_version = "1.8.24" 69 | s.summary = "JRuby API for Mondrian OLAP Java library" 70 | 71 | if s.respond_to? :specification_version then 72 | s.specification_version = 3 73 | 74 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 75 | s.add_runtime_dependency(%q, ["~> 1.5.0"]) 76 | s.add_development_dependency(%q, [">= 0"]) 77 | s.add_development_dependency(%q, ["~> 1.8.3"]) 78 | s.add_development_dependency(%q, [">= 0"]) 79 | s.add_development_dependency(%q, ["~> 2.10"]) 80 | s.add_development_dependency(%q, [">= 0"]) 81 | s.add_development_dependency(%q, [">= 0"]) 82 | s.add_development_dependency(%q, [">= 0"]) 83 | s.add_development_dependency(%q, [">= 0"]) 84 | s.add_development_dependency(%q, [">= 0"]) 85 | s.add_development_dependency(%q, ["= 3.2.8"]) 86 | s.add_development_dependency(%q, ["= 1.2.2"]) 87 | s.add_development_dependency(%q, [">= 0"]) 88 | s.add_development_dependency(%q, ["~> 2.2.0"]) 89 | s.add_development_dependency(%q, ["~> 1.73.1"]) 90 | s.add_development_dependency(%q, [">= 0"]) 91 | else 92 | s.add_dependency(%q, ["~> 1.5.0"]) 93 | s.add_dependency(%q, [">= 0"]) 94 | s.add_dependency(%q, ["~> 1.8.3"]) 95 | s.add_dependency(%q, [">= 0"]) 96 | s.add_dependency(%q, ["~> 2.10"]) 97 | s.add_dependency(%q, [">= 0"]) 98 | s.add_dependency(%q, [">= 0"]) 99 | s.add_dependency(%q, [">= 0"]) 100 | s.add_dependency(%q, [">= 0"]) 101 | s.add_dependency(%q, [">= 0"]) 102 | s.add_dependency(%q, ["= 3.2.8"]) 103 | s.add_dependency(%q, ["= 1.2.2"]) 104 | s.add_dependency(%q, [">= 0"]) 105 | s.add_dependency(%q, ["~> 2.2.0"]) 106 | s.add_dependency(%q, ["~> 1.73.1"]) 107 | s.add_dependency(%q, [">= 0"]) 108 | end 109 | else 110 | s.add_dependency(%q, ["~> 1.5.0"]) 111 | s.add_dependency(%q, [">= 0"]) 112 | s.add_dependency(%q, ["~> 1.8.3"]) 113 | s.add_dependency(%q, [">= 0"]) 114 | s.add_dependency(%q, ["~> 2.10"]) 115 | s.add_dependency(%q, [">= 0"]) 116 | s.add_dependency(%q, [">= 0"]) 117 | s.add_dependency(%q, [">= 0"]) 118 | s.add_dependency(%q, [">= 0"]) 119 | s.add_dependency(%q, [">= 0"]) 120 | s.add_dependency(%q, ["= 3.2.8"]) 121 | s.add_dependency(%q, ["= 1.2.2"]) 122 | s.add_dependency(%q, [">= 0"]) 123 | s.add_dependency(%q, ["~> 2.2.0"]) 124 | s.add_dependency(%q, ["~> 1.73.1"]) 125 | s.add_dependency(%q, [">= 0"]) 126 | end 127 | end 128 | 129 | -------------------------------------------------------------------------------- /spec/connection_role_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Connection role" do 4 | 5 | describe "create connection" do 6 | before(:each) do 7 | @role_name = role_name = 'California manager' 8 | @role_name2 = role_name2 = 'Dummy, with comma' 9 | @schema = Mondrian::OLAP::Schema.define do 10 | cube 'Sales' do 11 | table 'sales' 12 | dimension 'Gender', :foreign_key => 'customer_id' do 13 | hierarchy :has_all => true, :primary_key => 'id' do 14 | table 'customers' 15 | level 'Gender', :column => 'gender', :unique_members => true 16 | end 17 | end 18 | dimension 'Customers', :foreign_key => 'customer_id' do 19 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id' do 20 | table 'customers' 21 | level 'Country', :column => 'country', :unique_members => true 22 | level 'State Province', :column => 'state_province', :unique_members => true 23 | level 'City', :column => 'city', :unique_members => false 24 | level 'Name', :column => 'fullname', :unique_members => true 25 | end 26 | end 27 | dimension 'Time', :foreign_key => 'time_id' do 28 | hierarchy :has_all => false, :primary_key => 'id' do 29 | table 'time' 30 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true 31 | level 'Quarter', :column => 'quarter', :unique_members => false 32 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false 33 | end 34 | end 35 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum' 36 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 37 | end 38 | role role_name do 39 | schema_grant :access => 'none' do 40 | cube_grant :cube => 'Sales', :access => 'all' do 41 | dimension_grant :dimension => '[Measures]', :access => 'all' 42 | hierarchy_grant :hierarchy => '[Customers]', :access => 'custom', 43 | :top_level => '[Customers].[State Province]', :bottom_level => '[Customers].[City]' do 44 | member_grant :member => '[Customers].[USA].[CA]', :access => 'all' 45 | member_grant :member => '[Customers].[USA].[CA].[Los Angeles]', :access => 'none' 46 | end 47 | end 48 | end 49 | end 50 | role role_name2 51 | 52 | # to test that Role elements are generated before UserDefinedFunction 53 | user_defined_function 'Factorial' do 54 | ruby do 55 | parameters :numeric 56 | returns :numeric 57 | def call(n) 58 | n <= 1 ? 1 : n * call(n - 1) 59 | end 60 | end 61 | end 62 | end 63 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 64 | end 65 | 66 | it "should connect" do 67 | @olap.should be_connected 68 | end 69 | 70 | it "should get available role names" do 71 | @olap.available_role_names.should == [@role_name, @role_name2] 72 | end 73 | 74 | it "should not get role name if not set" do 75 | @olap.role_name.should be_nil 76 | @olap.role_names.should be_empty 77 | end 78 | 79 | it "should set and get role name" do 80 | @olap.role_name = @role_name 81 | @olap.role_name.should == @role_name 82 | @olap.role_names.should == [@role_name] 83 | end 84 | 85 | it "should raise error when invalid role name is set" do 86 | expect { 87 | @olap.role_name = 'invalid' 88 | }.to raise_error {|e| 89 | e.should be_kind_of(Mondrian::OLAP::Error) 90 | e.message.should == "org.olap4j.OlapException: Unknown role 'invalid'" 91 | e.root_cause_message.should == "Unknown role 'invalid'" 92 | } 93 | end 94 | 95 | it "should set and get several role names" do 96 | @olap.role_names = [@role_name, @role_name2] 97 | @olap.role_name.should == "[#{@role_name}, #{@role_name2}]" 98 | @olap.role_names.should == [@role_name, @role_name2] 99 | end 100 | 101 | it "should not get non-visible member" do 102 | @cube = @olap.cube('Sales') 103 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should_not be_nil 104 | @olap.role_name = @role_name 105 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 106 | end 107 | 108 | # TODO: investigate why role name is not returned when set in connection string 109 | # it "should set role name from connection parameters" do 110 | # @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema, 111 | # :role => @role_name) 112 | # @olap.role_name.should == @role_name 113 | # end 114 | 115 | it "should not get non-visible member when role name set in connection parameters" do 116 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema, 117 | :role => @role_name) 118 | @cube = @olap.cube('Sales') 119 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 120 | end 121 | 122 | it "should not get non-visible member when several role names set in connection parameters" do 123 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema, 124 | :roles => [@role_name, @role_name2]) 125 | @cube = @olap.cube('Sales') 126 | @cube.member('[Customers].[USA].[CA].[Los Angeles]').should be_nil 127 | end 128 | 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /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 | 54 | 55 | 56 | 57 | fname || ' ' || lname 58 | 59 | 60 | "fname" || ' ' || "lname" 61 | 62 | 63 | CONCAT(`customer`.`fname`, ' ', `customer`.`lname`) 64 | 65 | 66 | fname || ' ' || lname 67 | 68 | 69 | FULLNAME 70 | 71 | 72 | 73 | 74 | fname || ' ' || lname 75 | 76 | 77 | "fname" || ' ' || "lname" 78 | 79 | 80 | CONCAT(`customer`.`fname`, ' ', `customer`.`lname`) 81 | 82 | 83 | fname || ' ' || lname 84 | 85 | 86 | FULLNAME 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 | 97 | 98 | 99 | 100 | 102 | 104 | 106 | 108 | 110 | 113 | [Measures].[Store Sales] - [Measures].[Store Cost] 114 | 115 | 116 | 121 | 122 | 123 | 124 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /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 | 54 | 55 | 56 | 57 | fname || ' ' || lname 58 | 59 | 60 | "fname" || ' ' || "lname" 61 | 62 | 63 | CONCAT(`customers`.`fname`, ' ', `customers`.`lname`) 64 | 65 | 66 | "fname" || ' ' || "lname" 67 | 68 | 69 | fullname 70 | 71 | 72 | 73 | 74 | fname || ' ' || lname 75 | 76 | 77 | "fname" || ' ' || "lname" 78 | 79 | 80 | CONCAT(`customers`.`fname`, ' ', `customers`.`lname`) 81 | 82 | 83 | "fname" || ' ' || "lname" 84 | 85 | 86 | fullname 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 | 97 | 98 | 99 | 100 | 102 | 104 | 106 | 108 | 110 | 113 | [Measures].[Store Sales] - [Measures].[Store Cost] 114 | 115 | 116 | 121 | 122 | 123 | 124 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /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_catalog, :raw_schema 11 | 12 | def initialize(params={}) 13 | @params = params 14 | @driver = params[:driver] 15 | @connected = false 16 | @raw_connection = nil 17 | end 18 | 19 | def connect 20 | Error.wrap_native_exception do 21 | # hack to call private constructor of MondrianOlap4jDriver 22 | # to avoid using DriverManager which fails to load JDBC drivers 23 | # because of not seeing JRuby required jar files 24 | cons = Java::MondrianOlap4j::MondrianOlap4jDriver.java_class.declared_constructor 25 | cons.accessible = true 26 | driver = cons.new_instance.to_java 27 | 28 | props = java.util.Properties.new 29 | props.setProperty('JdbcUser', @params[:username]) if @params[:username] 30 | props.setProperty('JdbcPassword', @params[:password]) if @params[:password] 31 | 32 | conn_string = connection_string 33 | 34 | # latest Mondrian version added ClassResolver which uses current thread class loader to load some classes 35 | # therefore need to set it to JRuby class loader to ensure that Mondrian classes are found 36 | # (e.g. when running mondrian-olap inside OSGi container) 37 | current_thread = Java::JavaLang::Thread.currentThread 38 | class_loader = current_thread.getContextClassLoader 39 | begin 40 | current_thread.setContextClassLoader JRuby.runtime.jruby_class_loader 41 | @raw_jdbc_connection = driver.connect(conn_string, props) 42 | ensure 43 | current_thread.setContextClassLoader(class_loader) 44 | end 45 | 46 | @raw_connection = @raw_jdbc_connection.unwrap(Java::OrgOlap4j::OlapConnection.java_class) 47 | @raw_catalog = @raw_connection.getOlapCatalog 48 | # currently it is assumed that there is just one schema per connection catalog 49 | @raw_schema = @raw_catalog.getSchemas.first 50 | @connected = true 51 | true 52 | end 53 | end 54 | 55 | def connected? 56 | @connected 57 | end 58 | 59 | def close 60 | @raw_connection.close 61 | @connected = false 62 | @raw_connection = @raw_jdbc_connection = nil 63 | true 64 | end 65 | 66 | def execute(query_string) 67 | Error.wrap_native_exception do 68 | statement = @raw_connection.prepareOlapStatement(query_string) 69 | Result.new(self, statement.executeQuery()) 70 | end 71 | end 72 | 73 | def from(cube_name) 74 | Query.from(self, cube_name) 75 | end 76 | 77 | def cube_names 78 | @raw_schema.getCubes.map{|c| c.getName} 79 | end 80 | 81 | def cube(name) 82 | Cube.get(self, name) 83 | end 84 | 85 | # Will affect only the next created connection. If it is necessary to clear all schema cache then 86 | # flush_schema_cache should be called, then close and then new connection should be created. 87 | def flush_schema_cache 88 | unwrapped_connection = @raw_connection.unwrap(Java::MondrianOlap::Connection.java_class) 89 | raw_cache_control = unwrapped_connection.getCacheControl(nil) 90 | raw_cache_control.flushSchemaCache 91 | end 92 | 93 | def available_role_names 94 | @raw_connection.getAvailableRoleNames.to_a 95 | end 96 | 97 | def role_name 98 | @raw_connection.getRoleName 99 | end 100 | 101 | def role_names 102 | @raw_connection.getRoleNames.to_a 103 | end 104 | 105 | def role_name=(name) 106 | Error.wrap_native_exception do 107 | @raw_connection.setRoleName(name) 108 | end 109 | end 110 | 111 | def role_names=(names) 112 | Error.wrap_native_exception do 113 | @raw_connection.setRoleNames(Array(names)) 114 | end 115 | end 116 | 117 | private 118 | 119 | def connection_string 120 | string = "jdbc:mondrian:Jdbc=#{quote_string(jdbc_uri)};JdbcDrivers=#{jdbc_driver};" 121 | # by default use content checksum to reload schema when catalog has changed 122 | string << "UseContentChecksum=true;" unless @params[:use_content_checksum] == false 123 | if role = @params[:role] || @params[:roles] 124 | roles = Array(role).map{|r| r && r.to_s.gsub(',', ',,')}.compact 125 | string << "Role=#{quote_string(roles.join(','))};" unless roles.empty? 126 | end 127 | string << (@params[:catalog] ? "Catalog=#{catalog_uri}" : "CatalogContent=#{quote_string(catalog_content)}") 128 | end 129 | 130 | def jdbc_uri 131 | case @driver 132 | when 'mysql', 'postgresql' 133 | uri = "jdbc:#{@driver}://#{@params[:host]}#{@params[:port] && ":#{@params[:port]}"}/#{@params[:database]}" 134 | uri << "?useUnicode=yes&characterEncoding=UTF-8" if @driver == 'mysql' 135 | uri 136 | when 'oracle' 137 | # connection using TNS alias 138 | if @params[:database] && !@params[:host] && !@params[:url] && ENV['TNS_ADMIN'] 139 | "jdbc:oracle:thin:@#{@params[:database]}" 140 | else 141 | @params[:url] || 142 | "jdbc:oracle:thin:@#{@params[:host] || 'localhost'}:#{@params[:port] || 1521}:#{@params[:database]}" 143 | end 144 | when 'luciddb' 145 | uri = "jdbc:luciddb:http://#{@params[:host]}#{@params[:port] && ":#{@params[:port]}"}" 146 | uri << ";schema=#{@params[:database_schema]}" if @params[:database_schema] 147 | uri 148 | when 'mssql' 149 | uri = "jdbc:jtds:sqlserver://#{@params[:host]}#{@params[:port] && ":#{@params[:port]}"}/#{@params[:database]}" 150 | uri << ";instance=#{@params[:instance]}" if @params[:instance] 151 | uri << ";domain=#{@params[:domain]}" if @params[:domain] 152 | uri << ";appname=#{@params[:appname]}" if @params[:appname] 153 | uri 154 | when 'sqlserver' 155 | uri = "jdbc:sqlserver://#{@params[:host]}#{@params[:port] && ":#{@params[:port]}"}" 156 | uri << ";databaseName=#{@params[:database]}" if @params[:database] 157 | uri << ";integratedSecurity=#{@params[:integrated_security]}" if @params[:integrated_security] 158 | uri << ";applicationName=#{@params[:application_name]}" if @params[:application_name] 159 | uri << ";instanceName=#{@params[:instance_name]}" if @params[:instance_name] 160 | uri 161 | else 162 | raise ArgumentError, 'unknown JDBC driver' 163 | end 164 | end 165 | 166 | def jdbc_driver 167 | case @driver 168 | when 'mysql' 169 | 'com.mysql.jdbc.Driver' 170 | when 'postgresql' 171 | 'org.postgresql.Driver' 172 | when 'oracle' 173 | 'oracle.jdbc.OracleDriver' 174 | when 'luciddb' 175 | 'org.luciddb.jdbc.LucidDbClientDriver' 176 | when 'mssql' 177 | 'net.sourceforge.jtds.jdbc.Driver' 178 | when 'sqlserver' 179 | 'com.microsoft.sqlserver.jdbc.SQLServerDriver' 180 | else 181 | raise ArgumentError, 'unknown JDBC driver' 182 | end 183 | end 184 | 185 | def catalog_uri 186 | if @params[:catalog] 187 | "file://#{File.expand_path(@params[:catalog])}" 188 | else 189 | raise ArgumentError, 'missing catalog source' 190 | end 191 | end 192 | 193 | def catalog_content 194 | if @params[:catalog_content] 195 | @params[:catalog_content] 196 | elsif @params[:schema] 197 | @params[:schema].to_xml(:driver => @driver) 198 | else 199 | raise ArgumentError, "Specify catalog with :catalog, :catalog_content or :schema option" 200 | end 201 | end 202 | 203 | def quote_string(string) 204 | "'#{string.gsub("'","''")}'" 205 | end 206 | 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/mondrian/olap/cube.rb: -------------------------------------------------------------------------------- 1 | module Mondrian 2 | module OLAP 3 | class Cube 4 | def self.get(connection, name) 5 | if raw_cube = connection.raw_schema.getCubes.get(name) 6 | Cube.new(connection, raw_cube) 7 | end 8 | end 9 | 10 | def initialize(connection, raw_cube) 11 | @connection = connection 12 | @raw_cube = raw_cube 13 | end 14 | 15 | attr_reader :raw_cube 16 | 17 | def name 18 | @name ||= @raw_cube.getName 19 | end 20 | 21 | def description 22 | @description ||= @raw_cube.getDescription 23 | end 24 | 25 | def dimensions 26 | @dimenstions ||= @raw_cube.getDimensions.map{|d| Dimension.new(self, d)} 27 | end 28 | 29 | def dimension_names 30 | dimensions.map{|d| d.name} 31 | end 32 | 33 | def dimension(name) 34 | dimensions.detect{|d| d.name == name} 35 | end 36 | 37 | def query 38 | Query.from(@connection, name) 39 | end 40 | 41 | def member(full_name) 42 | segment_list = Java::OrgOlap4jMdx::IdentifierNode.parseIdentifier(full_name).getSegmentList 43 | raw_member = @raw_cube.lookupMember(segment_list) 44 | raw_member && Member.new(raw_member) 45 | end 46 | 47 | def member_by_segments(*segment_names) 48 | segment_list = Java::OrgOlap4jMdx::IdentifierNode.ofNames(*segment_names).getSegmentList 49 | raw_member = @raw_cube.lookupMember(segment_list) 50 | raw_member && Member.new(raw_member) 51 | end 52 | end 53 | 54 | class Dimension 55 | def initialize(cube, raw_dimension) 56 | @cube = cube 57 | @raw_dimension = raw_dimension 58 | end 59 | 60 | attr_reader :cube, :raw_dimension 61 | 62 | def name 63 | @name ||= @raw_dimension.getName 64 | end 65 | 66 | def description 67 | @description ||= @raw_dimension.getDescription 68 | end 69 | 70 | def full_name 71 | @full_name ||= @raw_dimension.getUniqueName 72 | end 73 | 74 | def hierarchies 75 | @hierarchies ||= @raw_dimension.getHierarchies.map{|h| Hierarchy.new(self, h)} 76 | end 77 | 78 | def hierarchy_names 79 | hierarchies.map{|h| h.name} 80 | end 81 | 82 | def hierarchy(name = nil) 83 | name ||= self.name 84 | hierarchies.detect{|h| h.name == name} 85 | end 86 | 87 | def measures? 88 | @raw_dimension.getDimensionType == Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 89 | end 90 | 91 | def dimension_type 92 | case @raw_dimension.getDimensionType 93 | when Java::OrgOlap4jMetadata::Dimension::Type::TIME 94 | :time 95 | when Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 96 | :measures 97 | else 98 | :standard 99 | end 100 | end 101 | end 102 | 103 | class Hierarchy 104 | def initialize(dimension, raw_hierarchy) 105 | @dimension = dimension 106 | @raw_hierarchy = raw_hierarchy 107 | end 108 | 109 | attr_reader :raw_hierarchy 110 | 111 | def name 112 | @name ||= @raw_hierarchy.getName 113 | end 114 | 115 | def description 116 | @description ||= @raw_hierarchy.getDescription 117 | end 118 | 119 | def levels 120 | @levels = @raw_hierarchy.getLevels.map{|l| Level.new(self, l)} 121 | end 122 | 123 | def level(name) 124 | levels.detect{|l| l.name == name} 125 | end 126 | 127 | def level_names 128 | levels.map{|l| l.name} 129 | end 130 | 131 | def has_all? 132 | @raw_hierarchy.hasAll 133 | end 134 | 135 | def all_member_name 136 | has_all? ? @raw_hierarchy.getRootMembers.first.getName : nil 137 | end 138 | 139 | def all_member 140 | has_all? ? Member.new(@raw_hierarchy.getRootMembers.first) : nil 141 | end 142 | 143 | def root_members 144 | @raw_hierarchy.getRootMembers.map{|m| Member.new(m)} 145 | end 146 | 147 | def root_member_names 148 | @raw_hierarchy.getRootMembers.map{|m| m.getName} 149 | end 150 | 151 | def root_member_full_names 152 | @raw_hierarchy.getRootMembers.map{|m| m.getUniqueName} 153 | end 154 | 155 | def child_names(*parent_member_segment_names) 156 | Error.wrap_native_exception do 157 | parent_member = if parent_member_segment_names.empty? 158 | return root_member_names unless has_all? 159 | all_member 160 | else 161 | @dimension.cube.member_by_segments(*parent_member_segment_names) 162 | end 163 | parent_member && parent_member.children.map{|m| m.name} 164 | end 165 | end 166 | end 167 | 168 | class Level 169 | def initialize(hierarchy, raw_level) 170 | @hierarchy = hierarchy 171 | @raw_level = raw_level 172 | end 173 | 174 | attr_reader :raw_level 175 | 176 | def name 177 | @name ||= @raw_level.getName 178 | end 179 | 180 | def description 181 | @description ||= @raw_level.getDescription 182 | end 183 | 184 | def depth 185 | @raw_level.getDepth 186 | end 187 | 188 | def cardinality 189 | @cardinality = @raw_level.getCardinality 190 | end 191 | 192 | def members_count 193 | @members_count ||= begin 194 | if cardinality >= 0 195 | cardinality 196 | else 197 | Error.wrap_native_exception do 198 | @raw_level.getMembers.size 199 | end 200 | end 201 | end 202 | end 203 | 204 | def members 205 | Error.wrap_native_exception do 206 | @raw_level.getMembers.map{|m| Member.new(m)} 207 | end 208 | end 209 | end 210 | 211 | class Member 212 | def initialize(raw_member) 213 | @raw_member = raw_member 214 | end 215 | 216 | attr_reader :raw_member 217 | 218 | def name 219 | @raw_member.getName 220 | end 221 | 222 | def full_name 223 | @raw_member.getUniqueName 224 | end 225 | 226 | def caption 227 | @raw_member.getCaption 228 | end 229 | 230 | def calculated? 231 | @raw_member.isCalculated 232 | end 233 | 234 | def calculated_in_query? 235 | @raw_member.isCalculatedInQuery 236 | end 237 | 238 | def visible? 239 | @raw_member.isVisible 240 | end 241 | 242 | def all_member? 243 | @raw_member.isAll 244 | end 245 | 246 | def drillable? 247 | return false if calculated? 248 | # @raw_member.getChildMemberCount > 0 249 | # This hopefully is faster than counting actual child members 250 | raw_level = @raw_member.getLevel 251 | raw_levels = raw_level.getHierarchy.getLevels 252 | raw_levels.indexOf(raw_level) < raw_levels.size - 1 253 | end 254 | 255 | def depth 256 | @raw_member.getDepth 257 | end 258 | 259 | def dimension_type 260 | case @raw_member.getDimension.getDimensionType 261 | when Java::OrgOlap4jMetadata::Dimension::Type::TIME 262 | :time 263 | when Java::OrgOlap4jMetadata::Dimension::Type::MEASURE 264 | :measures 265 | else 266 | :standard 267 | end 268 | end 269 | 270 | def children 271 | Error.wrap_native_exception do 272 | @raw_member.getChildMembers.map{|m| Member.new(m)} 273 | end 274 | end 275 | 276 | def descendants_at_level(level) 277 | Error.wrap_native_exception do 278 | raw_level = @raw_member.getLevel 279 | raw_levels = raw_level.getHierarchy.getLevels 280 | current_level_index = raw_levels.indexOf(raw_level) 281 | descendants_level_index = raw_levels.indexOfName(level) 282 | 283 | return nil unless descendants_level_index > current_level_index 284 | 285 | members = [self] 286 | (descendants_level_index - current_level_index).times do 287 | members = members.map do |member| 288 | member.children 289 | end.flatten 290 | end 291 | members 292 | end 293 | end 294 | 295 | def property_value(name) 296 | if property = @raw_member.getProperties.get(name) 297 | @raw_member.getPropertyValue(property) 298 | end 299 | end 300 | 301 | def property_formatted_value(name) 302 | if property = @raw_member.getProperties.get(name) 303 | @raw_member.getPropertyFormattedValue(property) 304 | end 305 | end 306 | 307 | end 308 | end 309 | end 310 | -------------------------------------------------------------------------------- /lib/mondrian/olap/result.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'bigdecimal' 3 | 4 | module Mondrian 5 | module OLAP 6 | class Result 7 | def initialize(connection, raw_cell_set) 8 | @connection = connection 9 | @raw_cell_set = raw_cell_set 10 | end 11 | 12 | def axes_count 13 | axes.length 14 | end 15 | 16 | def axis_names 17 | @axis_names ||= axis_positions(:getName) 18 | end 19 | 20 | def axis_full_names 21 | @axis_full_names ||= axis_positions(:getUniqueName) 22 | end 23 | 24 | def axis_members 25 | @axis_members ||= axis_positions(:to_member) 26 | end 27 | 28 | AXIS_SYMBOLS = [:column, :row, :page, :section, :chapter] 29 | AXIS_SYMBOLS.each_with_index do |axis, i| 30 | define_method :"#{axis}_names" do 31 | axis_names[i] 32 | end 33 | 34 | define_method :"#{axis}_full_names" do 35 | axis_full_names[i] 36 | end 37 | 38 | define_method :"#{axis}_members" do 39 | axis_members[i] 40 | end 41 | end 42 | 43 | def values(*axes_sequence) 44 | values_using(:getValue, axes_sequence) 45 | end 46 | 47 | def formatted_values(*axes_sequence) 48 | values_using(:getFormattedValue, axes_sequence) 49 | end 50 | 51 | def values_using(values_method, axes_sequence = []) 52 | if axes_sequence.empty? 53 | axes_sequence = (0...axes_count).to_a.reverse 54 | elsif axes_sequence.size != axes_count 55 | raise ArgumentError, "axes sequence size is not equal to result axes count" 56 | end 57 | recursive_values(values_method, axes_sequence, 0) 58 | end 59 | 60 | # format results in simple HTML table 61 | def to_html(options = {}) 62 | case axes_count 63 | when 1 64 | builder = Nokogiri::XML::Builder.new do |doc| 65 | doc.table do 66 | doc.tr do 67 | column_full_names.each do |column_full_name| 68 | column_full_name = column_full_name.join(',') if column_full_name.is_a?(Array) 69 | doc.th column_full_name, :align => 'right' 70 | end 71 | end 72 | doc.tr do 73 | (options[:formatted] ? formatted_values : values).each do |value| 74 | doc.td value, :align => 'right' 75 | end 76 | end 77 | end 78 | end 79 | builder.doc.to_html 80 | when 2 81 | builder = Nokogiri::XML::Builder.new do |doc| 82 | doc.table do 83 | doc.tr do 84 | doc.th 85 | column_full_names.each do |column_full_name| 86 | column_full_name = column_full_name.join(',') if column_full_name.is_a?(Array) 87 | doc.th column_full_name, :align => 'right' 88 | end 89 | end 90 | (options[:formatted] ? formatted_values : values).each_with_index do |row, i| 91 | doc.tr do 92 | row_full_name = row_full_names[i].is_a?(Array) ? row_full_names[i].join(',') : row_full_names[i] 93 | doc.th row_full_name, :align => 'left' 94 | row.each do |cell| 95 | doc.td cell, :align => 'right' 96 | end 97 | end 98 | end 99 | end 100 | end 101 | builder.doc.to_html 102 | else 103 | raise ArgumentError, "just columns and rows axes are supported" 104 | end 105 | end 106 | 107 | # specify drill through cell position, for example, as 108 | # :row => 0, :cell => 1 109 | # specify max returned rows with :max_rows parameter 110 | def drill_through(position_params = {}) 111 | Error.wrap_native_exception do 112 | cell_params = [] 113 | axes_count.times do |i| 114 | axis_symbol = AXIS_SYMBOLS[i] 115 | raise ArgumentError, "missing position #{axis_symbol.inspect}" unless axis_position = position_params[axis_symbol] 116 | cell_params << Java::JavaLang::Integer.new(axis_position) 117 | end 118 | raw_cell = @raw_cell_set.getCell(cell_params) 119 | DrillThrough.from_raw_cell(raw_cell, position_params) 120 | end 121 | end 122 | 123 | class DrillThrough 124 | def self.from_raw_cell(raw_cell, params = {}) 125 | max_rows = params[:max_rows] || -1 126 | # workaround to avoid calling raw_cell.drillThroughInternal private method 127 | # which fails when running inside TorqueBox 128 | cell_field = raw_cell.java_class.declared_field('cell') 129 | cell_field.accessible = true 130 | rolap_cell = cell_field.value(raw_cell) 131 | if rolap_cell.canDrillThrough 132 | sql_statement = rolap_cell.drillThroughInternal(max_rows, -1, nil, true, nil) 133 | raw_result_set = sql_statement.getWrappedResultSet 134 | new(raw_result_set) 135 | end 136 | end 137 | 138 | def initialize(raw_result_set) 139 | @raw_result_set = raw_result_set 140 | end 141 | 142 | def column_types 143 | @column_types ||= (1..metadata.getColumnCount).map{|i| metadata.getColumnTypeName(i).to_sym} 144 | end 145 | 146 | def column_names 147 | @column_names ||= begin 148 | # if PostgreSQL then use getBaseColumnName as getColumnName returns empty string 149 | if metadata.respond_to?(:getBaseColumnName) 150 | (1..metadata.getColumnCount).map{|i| metadata.getBaseColumnName(i)} 151 | else 152 | (1..metadata.getColumnCount).map{|i| metadata.getColumnName(i)} 153 | end 154 | end 155 | end 156 | 157 | def table_names 158 | @table_names ||= begin 159 | # if PostgreSQL then use getBaseTableName as getTableName returns empty string 160 | if metadata.respond_to?(:getBaseTableName) 161 | (1..metadata.getColumnCount).map{|i| metadata.getBaseTableName(i)} 162 | else 163 | (1..metadata.getColumnCount).map{|i| metadata.getTableName(i)} 164 | end 165 | end 166 | end 167 | 168 | def column_labels 169 | @column_labels ||= (1..metadata.getColumnCount).map{|i| metadata.getColumnLabel(i)} 170 | end 171 | 172 | def fetch 173 | if @raw_result_set.next 174 | row_values = [] 175 | column_types.each_with_index do |column_type, i| 176 | row_values << Result.java_to_ruby_value(@raw_result_set.getObject(i+1), column_type) 177 | end 178 | row_values 179 | else 180 | @raw_result_set.close 181 | nil 182 | end 183 | end 184 | 185 | def rows 186 | @rows ||= begin 187 | rows_values = [] 188 | while row_values = fetch 189 | rows_values << row_values 190 | end 191 | rows_values 192 | end 193 | end 194 | 195 | private 196 | 197 | def metadata 198 | @metadata ||= @raw_result_set.getMetaData 199 | end 200 | 201 | end 202 | 203 | def self.java_to_ruby_value(value, column_type = nil) 204 | case value 205 | when Numeric, String 206 | value 207 | when Java::JavaMath::BigDecimal 208 | BigDecimal(value.to_s) 209 | else 210 | value 211 | end 212 | end 213 | 214 | private 215 | 216 | def axes 217 | @axes ||= @raw_cell_set.getAxes 218 | end 219 | 220 | def axis_positions(map_method, join_with=false) 221 | axes.map do |axis| 222 | axis.getPositions.map do |position| 223 | names = position.getMembers.map do |member| 224 | if map_method == :to_member 225 | Member.new(member) 226 | else 227 | member.send(map_method) 228 | end 229 | end 230 | if names.size == 1 231 | names[0] 232 | elsif join_with 233 | names.join(join_with) 234 | else 235 | names 236 | end 237 | end 238 | end 239 | end 240 | 241 | AXIS_SYMBOL_TO_NUMBER = { 242 | :columns => 0, 243 | :rows => 1, 244 | :pages => 2, 245 | :sections => 3, 246 | :chapters => 4 247 | }.freeze 248 | 249 | def recursive_values(value_method, axes_sequence, current_index, cell_params=[]) 250 | if axis_number = axes_sequence[current_index] 251 | axis_number = AXIS_SYMBOL_TO_NUMBER[axis_number] if axis_number.is_a?(Symbol) 252 | positions_size = axes[axis_number].getPositions.size 253 | (0...positions_size).map do |i| 254 | cell_params[axis_number] = Java::JavaLang::Integer.new(i) 255 | recursive_values(value_method, axes_sequence, current_index + 1, cell_params) 256 | end 257 | else 258 | self.class.java_to_ruby_value(@raw_cell_set.getCell(cell_params).send(value_method)) 259 | end 260 | end 261 | 262 | end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /lib/mondrian/olap/schema_udf.rb: -------------------------------------------------------------------------------- 1 | require 'jruby/core_ext' 2 | 3 | module Mondrian 4 | module OLAP 5 | class Schema < SchemaElement 6 | 7 | module ScriptElements 8 | def javascript(text) 9 | script text, :language => 'JavaScript' 10 | end 11 | 12 | private 13 | 14 | def coffeescript_function(arguments_string, text) 15 | # construct function to ensure that last expression is returned 16 | coffee_text = "#{arguments_string} ->\n" << text.gsub(/^/,' ') 17 | javascript_text = CoffeeScript.compile(coffee_text, :bare => true) 18 | # remove function definition first and last lines 19 | javascript_text = javascript_text.strip.lines.to_a[1..-2].join 20 | javascript javascript_text 21 | end 22 | 23 | def ruby_formatter(interface_class, method, signature, &block) 24 | formatter_class = Class.new 25 | formatter_class.class_eval do 26 | include interface_class 27 | define_method method, &block 28 | add_method_signature(method, signature) 29 | end 30 | formatter_java_class = formatter_class.become_java!(false) 31 | class_name formatter_java_class.getName 32 | end 33 | end 34 | 35 | class UserDefinedFunction < SchemaElement 36 | include ScriptElements 37 | attributes :name, # Name with which the user-defined function will be referenced in MDX expressions. 38 | # Name of the class which implemenets this user-defined function. 39 | # Must implement the mondrian.spi.UserDefinedFunction interface. 40 | :class_name 41 | elements :script 42 | 43 | def coffeescript(text) 44 | coffee_text = "__udf__ = {\n" << text << "}\n" 45 | javascript_text = CoffeeScript.compile(coffee_text, :bare => true) 46 | javascript_text << <<-JS 47 | 48 | __udf__.parameters || (__udf__.parameters = []); 49 | __udf__.returns || (__udf__.returns = "Scalar"); 50 | var __scalarTypes__ = {"Numeric":true,"String":true,"Boolean":true,"DateTime":true,"Decimal":true,"Scalar":true}; 51 | function __getType__(type) { 52 | if (__scalarTypes__[type]) { 53 | return new mondrian.olap.type[type+"Type"]; 54 | } else if (type === "Member") { 55 | return mondrian.olap.type.MemberType.Unknown; 56 | } else { 57 | return null; 58 | } 59 | } 60 | function getParameterTypes() { 61 | var parameters = __udf__.parameters || [], 62 | types = []; 63 | for (var i = 0, len = parameters.length; i < len; i++) { 64 | types.push(__getType__(parameters[i])) 65 | } 66 | return types; 67 | } 68 | function getReturnType(parameterTypes) { 69 | var returns = __udf__.returns || "Scalar"; 70 | return __getType__(returns); 71 | } 72 | if (__udf__.syntax) { 73 | function getSyntax() { 74 | return mondrian.olap.Syntax[__udf__.syntax]; 75 | } 76 | } 77 | function execute(evaluator, args) { 78 | var parameters = __udf__.parameters || [], 79 | values = [], 80 | value; 81 | for (var i = 0, len = parameters.length; i < len; i++) { 82 | if (__scalarTypes__[parameters[i]]) { 83 | value = args[i].evaluateScalar(evaluator); 84 | } else { 85 | value = args[i].evaluate(evaluator); 86 | } 87 | values.push(value); 88 | } 89 | return __udf__.execute.apply(__udf__, values); 90 | } 91 | JS 92 | javascript javascript_text 93 | end 94 | 95 | class RubyUdfBase 96 | include Java::mondrian.spi.UserDefinedFunction 97 | def self.function_name=(name); @function_name = name; end 98 | def self.function_name; @function_name; end 99 | 100 | def getName 101 | self.class.function_name 102 | end 103 | add_method_signature("getName", [java.lang.String]) 104 | 105 | def getDescription 106 | getName 107 | end 108 | add_method_signature("getDescription", [java.lang.String]) 109 | 110 | def self.parameters(*types) 111 | if types.empty? 112 | @parameters || [] 113 | else 114 | @parameters = types.map{|type| stringified_type(type)} 115 | end 116 | end 117 | 118 | def self.returns(type = nil) 119 | if type 120 | @returns = stringified_type(type) 121 | else 122 | @returns || 'Scalar' 123 | end 124 | end 125 | 126 | VALID_SYNTAX_TYPES = %w(Function Property Method) 127 | def self.syntax(type = nil) 128 | if type 129 | type = stringify(type) 130 | raise ArgumentError, "invalid user defined function type #{type.inspect}" unless VALID_SYNTAX_TYPES.include? type 131 | @syntax = type 132 | else 133 | @syntax || 'Function' 134 | end 135 | end 136 | 137 | def getSyntax 138 | Java::mondrian.olap.Syntax.const_get self.class.syntax 139 | end 140 | add_method_signature("getSyntax", [Java::mondrian.olap.Syntax]) 141 | 142 | UDF_SCALAR_TYPES = { 143 | "Numeric" => Java::mondrian.olap.type.NumericType, 144 | "String" => Java::mondrian.olap.type.StringType, 145 | "Boolean" => Java::mondrian.olap.type.BooleanType, 146 | "DateTime" => Java::mondrian.olap.type.DateTimeType, 147 | "Decimal" => Java::mondrian.olap.type.DecimalType, 148 | "Scalar" => Java::mondrian.olap.type.ScalarType 149 | } 150 | UDF_OTHER_TYPES = { 151 | "Member" => Java::mondrian.olap.type.MemberType::Unknown, 152 | "Set" => Java::mondrian.olap.type.SetType.new(Java::mondrian.olap.type.MemberType::Unknown), 153 | "Hierarchy" => Java::mondrian.olap.type.HierarchyType.new(nil, nil), 154 | "Level" => Java::mondrian.olap.type.LevelType::Unknown 155 | } 156 | 157 | def getParameterTypes 158 | @parameterTypes ||= self.class.parameters.map{|p| get_java_type(p)} 159 | end 160 | class_loader = JRuby.runtime.jruby_class_loader 161 | type_array_class = java.lang.Class.forName "[Lmondrian.olap.type.Type;", true, class_loader 162 | add_method_signature("getParameterTypes", [type_array_class]) 163 | 164 | def getReturnType(parameterTypes) 165 | @returnType ||= get_java_type self.class.returns 166 | end 167 | add_method_signature("getReturnType", [Java::mondrian.olap.type.Type, type_array_class]) 168 | 169 | def getReservedWords 170 | nil 171 | end 172 | string_array_class = java.lang.Class.forName "[Ljava.lang.String;", true, class_loader 173 | add_method_signature("getReservedWords", [string_array_class]) 174 | 175 | def execute(evaluator, arguments) 176 | values = [] 177 | self.class.parameters.each_with_index do |p,i| 178 | value = UDF_SCALAR_TYPES[p] ? arguments[i].evaluateScalar(evaluator) : arguments[i].evaluate(evaluator) 179 | values << value 180 | end 181 | call_with_evaluator(evaluator, *values) 182 | end 183 | arguments_array_class = java.lang.Class.forName "[Lmondrian.spi.UserDefinedFunction$Argument;", true, class_loader 184 | add_method_signature("execute", [java.lang.Object, Java::mondrian.olap.Evaluator, arguments_array_class]) 185 | 186 | # Override this metho if evaluator is needed 187 | def call_with_evaluator(evaluator, *values) 188 | call(*values) 189 | end 190 | 191 | private 192 | 193 | def get_java_type(type) 194 | if type_class = UDF_SCALAR_TYPES[type] 195 | type_class.new 196 | else 197 | UDF_OTHER_TYPES[type] 198 | end 199 | end 200 | 201 | def self.stringified_type(type) 202 | type = stringify(type) 203 | raise ArgumentError, "invalid user defined function type #{type.inspect}" unless UDF_SCALAR_TYPES[type] || UDF_OTHER_TYPES[type] 204 | type 205 | end 206 | 207 | def self.stringify(arg) 208 | arg = arg.to_s.split('_').map{|s| s.capitalize}.join if arg.is_a? Symbol 209 | arg 210 | end 211 | end 212 | 213 | def ruby(*options, &block) 214 | udf_class_name = if options.include?(:shared) 215 | "#{name.capitalize}Udf" 216 | end 217 | if udf_class_name && self.class.const_defined?(udf_class_name) 218 | udf_class = self.class.const_get(udf_class_name) 219 | else 220 | udf_class = Class.new(RubyUdfBase) 221 | self.class.const_set(udf_class_name, udf_class) if udf_class_name 222 | end 223 | udf_class.function_name = name 224 | udf_class.class_eval(&block) 225 | udf_java_class = udf_class.become_java!(false) 226 | 227 | class_name udf_java_class.getName 228 | end 229 | end 230 | 231 | class Script < SchemaElement 232 | attributes :language 233 | content :text 234 | end 235 | 236 | class CellFormatter < SchemaElement 237 | include ScriptElements 238 | # Name of a formatter class for the appropriate cell being displayed. 239 | # The class must implement the mondrian.olap.CellFormatter interface. 240 | attributes :class_name 241 | elements :script 242 | 243 | def coffeescript(text) 244 | coffeescript_function('(value)', text) 245 | end 246 | 247 | def ruby(&block) 248 | ruby_formatter(Java::mondrian.spi.CellFormatter, 'formatCell', [java.lang.String, java.lang.Object], &block) 249 | end 250 | end 251 | 252 | class MemberFormatter < SchemaElement 253 | include ScriptElements 254 | attributes :class_name 255 | elements :script 256 | 257 | def coffeescript(text) 258 | coffeescript_function('(member)', text) 259 | end 260 | 261 | def ruby(&block) 262 | ruby_formatter(Java::mondrian.spi.MemberFormatter, 'formatMember', [java.lang.String, Java::mondrian.olap.Member], &block) 263 | end 264 | end 265 | 266 | class PropertyFormatter < SchemaElement 267 | include ScriptElements 268 | attributes :class_name 269 | elements :script 270 | 271 | def coffeescript(text) 272 | coffeescript_function('(member,propertyName,propertyValue)', text) 273 | end 274 | 275 | def ruby(&block) 276 | ruby_formatter(Java::mondrian.spi.PropertyFormatter, 'formatProperty', [java.lang.String, Java::mondrian.olap.Member, java.lang.String, java.lang.Object], &block) 277 | end 278 | end 279 | 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /spec/rake_tasks.rb: -------------------------------------------------------------------------------- 1 | namespace :db do 2 | task :require_spec_helper do 3 | require File.expand_path("../spec_helper", __FILE__) 4 | end 5 | 6 | desc "Create test database tables" 7 | task :create_tables => :require_spec_helper do 8 | puts "==> Creating tables for test data" 9 | ActiveRecord::Schema.define do 10 | 11 | create_table :time, :force => true do |t| 12 | t.datetime :the_date 13 | t.string :the_day, :limit => 30 14 | t.string :the_month, :limit => 30 15 | t.integer :the_year 16 | t.integer :day_of_month 17 | t.integer :week_of_year 18 | t.integer :month_of_year 19 | t.string :quarter, :limit => 30 20 | end 21 | 22 | create_table :products, :force => true do |t| 23 | t.integer :product_class_id 24 | t.string :brand_name, :limit => 60 25 | t.string :product_name, :limit => 60 26 | end 27 | 28 | create_table :product_classes, :force => true do |t| 29 | t.string :product_subcategory, :limit => 30 30 | t.string :product_category, :limit => 30 31 | t.string :product_department, :limit => 30 32 | t.string :product_family, :limit => 30 33 | end 34 | 35 | create_table :customers, :force => true do |t| 36 | t.string :country, :limit => 30 37 | t.string :state_province, :limit => 30 38 | t.string :city, :limit => 30 39 | t.string :fname, :limit => 30 40 | t.string :lname, :limit => 30 41 | t.string :fullname, :limit => 60 42 | t.string :gender, :limit => 30 43 | end 44 | 45 | create_table :sales, :force => true, :id => false do |t| 46 | t.integer :product_id 47 | t.integer :time_id 48 | t.integer :customer_id 49 | t.decimal :store_sales, :precision => 10, :scale => 4 50 | t.decimal :store_cost, :precision => 10, :scale => 4 51 | t.decimal :unit_sales, :precision => 10, :scale => 4 52 | end 53 | end 54 | end 55 | 56 | task :setup_luciddb => :require_spec_helper do 57 | # create link to mysql database to import tables 58 | # see description at http://pub.eigenbase.org/wiki/LucidDbCreateForeignServer 59 | if MONDRIAN_DRIVER == 'luciddb' 60 | conn = ActiveRecord::Base.connection 61 | conn.execute "drop schema mondrian_test_source cascade" rescue nil 62 | conn.execute "drop server mondrian_test_source" rescue nil 63 | conn.execute "create schema mondrian_test_source" 64 | conn.execute <<-SQL 65 | create server mondrian_test_source 66 | foreign data wrapper sys_jdbc 67 | options( 68 | driver_class 'com.mysql.jdbc.Driver', 69 | url 'jdbc:mysql://localhost/mondrian_test?characterEncoding=utf-8&useCursorFetch=true', 70 | user_name 'mondrian_test', 71 | password 'mondrian_test', 72 | login_timeout '10', 73 | fetch_size '1000', 74 | validation_query 'select 1', 75 | schema_name 'MONDRIAN_TEST', 76 | table_types 'TABLE') 77 | SQL 78 | conn.execute "import foreign schema mondrian_test from server mondrian_test_source into mondrian_test_source" 79 | end 80 | end 81 | 82 | task :define_models => :require_spec_helper do 83 | unless MONDRIAN_DRIVER == 'luciddb' 84 | class TimeDimension < ActiveRecord::Base 85 | self.table_name = "time" 86 | validates_presence_of :the_date 87 | before_create do 88 | self.the_day = the_date.strftime("%A") 89 | self.the_month = the_date.strftime("%B") 90 | self.the_year = the_date.strftime("%Y").to_i 91 | self.day_of_month = the_date.strftime("%d").to_i 92 | self.week_of_year = the_date.strftime("%W").to_i 93 | self.month_of_year = the_date.strftime("%m").to_i 94 | self.quarter = "Q#{(month_of_year-1)/3+1}" 95 | end 96 | end 97 | class Product < ActiveRecord::Base 98 | belongs_to :product_class 99 | end 100 | class ProductClass < ActiveRecord::Base 101 | end 102 | class Customer < ActiveRecord::Base 103 | end 104 | class Sales < ActiveRecord::Base 105 | self.table_name = "sales" 106 | belongs_to :time_by_day 107 | belongs_to :product 108 | belongs_to :customer 109 | end 110 | end 111 | end 112 | 113 | desc "Create test data" 114 | task :create_data => [:create_tables, :setup_luciddb, :create_time_data, :create_product_data, :create_customer_data, :create_sales_data] 115 | 116 | task :create_time_data => :define_models do 117 | puts "==> Creating time dimension" 118 | if MONDRIAN_DRIVER == 'luciddb' 119 | ActiveRecord::Base.connection.execute 'truncate table "TIME"' 120 | ActiveRecord::Base.connection.execute 'insert into "TIME" select * from mondrian_test_source."time"' 121 | ActiveRecord::Base.connection.execute 'analyze table "TIME" compute statistics for all columns' 122 | else 123 | TimeDimension.delete_all 124 | start_time = Time.local(2010,1,1) 125 | (2*365).times do |i| 126 | TimeDimension.create!(:the_date => start_time + i.day) 127 | end 128 | end 129 | end 130 | 131 | task :create_product_data => :define_models do 132 | puts "==> Creating product data" 133 | if MONDRIAN_DRIVER == 'luciddb' 134 | ActiveRecord::Base.connection.execute 'truncate table product_classes' 135 | ActiveRecord::Base.connection.execute 'truncate table products' 136 | ActiveRecord::Base.connection.execute 'insert into product_classes select * from mondrian_test_source."product_classes"' 137 | ActiveRecord::Base.connection.execute 'insert into products select * from mondrian_test_source."products"' 138 | ActiveRecord::Base.connection.execute 'analyze table product_classes compute statistics for all columns' 139 | ActiveRecord::Base.connection.execute 'analyze table products compute statistics for all columns' 140 | else 141 | Product.delete_all 142 | ProductClass.delete_all 143 | families = ["Drink", "Food", "Non-Consumable"] 144 | (1..100).each do |i| 145 | product_class = ProductClass.create!( 146 | :product_family => families[i % 3], 147 | :product_department => "Department #{i}", 148 | :product_category => "Category #{i}", 149 | :product_subcategory => "Subcategory #{i}" 150 | ) 151 | Product.create!( 152 | # LucidDB is not returning inserted ID therefore doing it hard way 153 | :product_class_id => ProductClass.find_all_by_product_category("Category #{i}").first.id, 154 | :brand_name => "Brand #{i}", 155 | :product_name => "Product #{i}" 156 | ) 157 | end 158 | end 159 | end 160 | 161 | task :create_customer_data => :define_models do 162 | puts "==> Creating customer data" 163 | if MONDRIAN_DRIVER == 'luciddb' 164 | ActiveRecord::Base.connection.execute 'truncate table customers' 165 | ActiveRecord::Base.connection.execute 'insert into customers select * from mondrian_test_source."customers"' 166 | ActiveRecord::Base.connection.execute 'analyze table customers compute statistics for all columns' 167 | else 168 | Customer.delete_all 169 | i = 0 170 | [ 171 | ["Canada", "BC", "Burnaby"],["Canada", "BC", "Cliffside"],["Canada", "BC", "Haney"],["Canada", "BC", "Ladner"], 172 | ["Canada", "BC", "Langford"],["Canada", "BC", "Langley"],["Canada", "BC", "Metchosin"],["Canada", "BC", "N. Vancouver"], 173 | ["Canada", "BC", "Newton"],["Canada", "BC", "Oak Bay"],["Canada", "BC", "Port Hammond"],["Canada", "BC", "Richmond"], 174 | ["Canada", "BC", "Royal Oak"],["Canada", "BC", "Shawnee"],["Canada", "BC", "Sooke"],["Canada", "BC", "Vancouver"], 175 | ["Canada", "BC", "Victoria"],["Canada", "BC", "Westminster"], 176 | ["Mexico", "DF", "San Andres"],["Mexico", "DF", "Santa Anita"],["Mexico", "DF", "Santa Fe"],["Mexico", "DF", "Tixapan"], 177 | ["Mexico", "Guerrero", "Acapulco"],["Mexico", "Jalisco", "Guadalajara"],["Mexico", "Mexico", "Mexico City"], 178 | ["Mexico", "Oaxaca", "Tlaxiaco"],["Mexico", "Sinaloa", "La Cruz"],["Mexico", "Veracruz", "Orizaba"], 179 | ["Mexico", "Yucatan", "Merida"],["Mexico", "Zacatecas", "Camacho"],["Mexico", "Zacatecas", "Hidalgo"], 180 | ["USA", "CA", "Altadena"],["USA", "CA", "Arcadia"],["USA", "CA", "Bellflower"],["USA", "CA", "Berkeley"], 181 | ["USA", "CA", "Beverly Hills"],["USA", "CA", "Burbank"],["USA", "CA", "Burlingame"],["USA", "CA", "Chula Vista"], 182 | ["USA", "CA", "Colma"],["USA", "CA", "Concord"],["USA", "CA", "Coronado"],["USA", "CA", "Daly City"], 183 | ["USA", "CA", "Downey"],["USA", "CA", "El Cajon"],["USA", "CA", "Fremont"],["USA", "CA", "Glendale"], 184 | ["USA", "CA", "Grossmont"],["USA", "CA", "Imperial Beach"],["USA", "CA", "La Jolla"],["USA", "CA", "La Mesa"], 185 | ["USA", "CA", "Lakewood"],["USA", "CA", "Lemon Grove"],["USA", "CA", "Lincoln Acres"],["USA", "CA", "Long Beach"], 186 | ["USA", "CA", "Los Angeles"],["USA", "CA", "Mill Valley"],["USA", "CA", "National City"],["USA", "CA", "Newport Beach"], 187 | ["USA", "CA", "Novato"],["USA", "CA", "Oakland"],["USA", "CA", "Palo Alto"],["USA", "CA", "Pomona"], 188 | ["USA", "CA", "Redwood City"],["USA", "CA", "Richmond"],["USA", "CA", "San Carlos"],["USA", "CA", "San Diego"], 189 | ["USA", "CA", "San Francisco"],["USA", "CA", "San Gabriel"],["USA", "CA", "San Jose"],["USA", "CA", "Santa Cruz"], 190 | ["USA", "CA", "Santa Monica"],["USA", "CA", "Spring Valley"],["USA", "CA", "Torrance"],["USA", "CA", "West Covina"], 191 | ["USA", "CA", "Woodland Hills"], 192 | ["USA", "OR", "Albany"],["USA", "OR", "Beaverton"],["USA", "OR", "Corvallis"],["USA", "OR", "Lake Oswego"], 193 | ["USA", "OR", "Lebanon"],["USA", "OR", "Milwaukie"],["USA", "OR", "Oregon City"],["USA", "OR", "Portland"], 194 | ["USA", "OR", "Salem"],["USA", "OR", "W. Linn"],["USA", "OR", "Woodburn"], 195 | ["USA", "WA", "Anacortes"],["USA", "WA", "Ballard"],["USA", "WA", "Bellingham"],["USA", "WA", "Bremerton"], 196 | ["USA", "WA", "Burien"],["USA", "WA", "Edmonds"],["USA", "WA", "Everett"],["USA", "WA", "Issaquah"], 197 | ["USA", "WA", "Kirkland"],["USA", "WA", "Lynnwood"],["USA", "WA", "Marysville"],["USA", "WA", "Olympia"], 198 | ["USA", "WA", "Port Orchard"],["USA", "WA", "Puyallup"],["USA", "WA", "Redmond"],["USA", "WA", "Renton"], 199 | ["USA", "WA", "Seattle"],["USA", "WA", "Sedro Woolley"],["USA", "WA", "Spokane"],["USA", "WA", "Tacoma"], 200 | ["USA", "WA", "Walla Walla"],["USA", "WA", "Yakima"] 201 | ].each do |country, state, city| 202 | i += 1 203 | Customer.create!( 204 | :country => country, 205 | :state_province => state, 206 | :city => city, 207 | :fname => "First#{i}", 208 | :lname => "Last#{i}", 209 | :fullname => "First#{i} Last#{i}", 210 | :gender => i % 2 == 0 ? "M" : "F" 211 | ) 212 | end 213 | end 214 | end 215 | 216 | task :create_sales_data => :define_models do 217 | puts "==> Creating sales data" 218 | if MONDRIAN_DRIVER == 'luciddb' 219 | ActiveRecord::Base.connection.execute 'truncate table sales' 220 | ActiveRecord::Base.connection.execute 'insert into sales select * from mondrian_test_source."sales"' 221 | ActiveRecord::Base.connection.execute 'analyze table sales compute statistics for all columns' 222 | else 223 | Sales.delete_all 224 | count = 100 225 | # LucidDB does not support LIMIT therefore select all and limit in Ruby 226 | products = Product.order("id").all[0...count] 227 | times = TimeDimension.order("id").all[0...count] 228 | customers = Customer.order("id").all[0...count] 229 | count.times do |i| 230 | Sales.create!( 231 | :product_id => products[i].id, 232 | :time_id => times[i].id, 233 | :customer_id => customers[i].id, 234 | :store_sales => BigDecimal("2#{i}.12"), 235 | :store_cost => BigDecimal("1#{i}.1234"), 236 | :unit_sales => i+1 237 | ) 238 | end 239 | end 240 | end 241 | 242 | end 243 | 244 | namespace :spec do 245 | %w(mysql postgresql oracle luciddb mssql sqlserver).each do |driver| 246 | desc "Run specs with #{driver} driver" 247 | task driver do 248 | ENV['MONDRIAN_DRIVER'] = driver 249 | Rake::Task['spec'].reenable 250 | Rake::Task['spec'].invoke 251 | end 252 | end 253 | 254 | desc "Run specs with all database drivers" 255 | task :all do 256 | %w(mysql postgresql oracle luciddb mssql sqlserver).each do |driver| 257 | Rake::Task["spec:#{driver}"].invoke 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /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 | cube 'Sales' do 7 | description 'Sales description' 8 | table 'sales' 9 | dimension 'Gender', :foreign_key => 'customer_id' do 10 | description 'Gender description' 11 | hierarchy :has_all => true, :primary_key => 'id' do 12 | description 'Gender hierarchy description' 13 | table 'customers' 14 | level 'Gender', :column => 'gender', :unique_members => true, :description => 'Gender level description' 15 | end 16 | end 17 | dimension 'Customers', :foreign_key => 'customer_id' do 18 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id' do 19 | table 'customers' 20 | level 'Country', :column => 'country', :unique_members => true 21 | level 'State Province', :column => 'state_province', :unique_members => true 22 | level 'City', :column => 'city', :unique_members => false 23 | level 'Name', :column => 'fullname', :unique_members => true 24 | end 25 | end 26 | calculated_member 'Non-USA' do 27 | dimension 'Customers' 28 | formula '[Customers].[All Customers] - [Customers].[USA]' 29 | end 30 | dimension 'Time', :foreign_key => 'time_id', :type => 'TimeDimension' do 31 | hierarchy :has_all => false, :primary_key => 'id' do 32 | table 'time' 33 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 34 | level 'Quarter', :column => 'quarter', :unique_members => false, :level_type => 'TimeQuarters' 35 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeMonths' 36 | end 37 | hierarchy 'Weekly', :has_all => false, :primary_key => 'id' do 38 | table 'time' 39 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 40 | level 'Week', :column => 'weak_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeWeeks' 41 | end 42 | end 43 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum' 44 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 45 | measure 'Store Cost', :column => 'store_cost', :aggregator => 'sum', :visible => false 46 | end 47 | end 48 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS.merge :schema => @schema) 49 | end 50 | 51 | it "should get all cube names" do 52 | @olap.cube_names.should == ['Sales'] 53 | end 54 | 55 | it "should get cube by name" do 56 | @olap.cube('Sales').should be_a(Mondrian::OLAP::Cube) 57 | end 58 | 59 | it "should return nil when getting cube with invalid name" do 60 | @olap.cube('invalid').should be_nil 61 | end 62 | 63 | it "should get cube name" do 64 | @olap.cube('Sales').name.should == 'Sales' 65 | end 66 | 67 | it "should get cube description" do 68 | @olap.cube('Sales').description.should == 'Sales description' 69 | end 70 | 71 | describe "dimensions" do 72 | before(:all) do 73 | @cube = @olap.cube('Sales') 74 | @dimension_names = ['Measures', 'Gender', 'Customers', 'Time'] 75 | end 76 | 77 | it "should get dimension names" do 78 | @cube.dimension_names.should == @dimension_names 79 | end 80 | 81 | it "should get dimensions" do 82 | @cube.dimensions.map{|d| d.name}.should == @dimension_names 83 | end 84 | 85 | it "should get dimension by name" do 86 | @cube.dimension('Gender').name.should == 'Gender' 87 | end 88 | 89 | it "should return nil when getting dimension with invalid name" do 90 | @cube.dimension('invalid').should be_nil 91 | end 92 | 93 | it "should get dimension description" do 94 | @cube.dimension('Gender').description.should == 'Gender description' 95 | end 96 | 97 | it "should get dimension full name" do 98 | @cube.dimension('Gender').full_name.should == '[Gender]' 99 | end 100 | 101 | it "should get measures dimension" do 102 | @cube.dimension('Measures').should be_measures 103 | end 104 | 105 | it "should get dimension type" do 106 | @cube.dimension('Gender').dimension_type.should == :standard 107 | @cube.dimension('Time').dimension_type.should == :time 108 | @cube.dimension('Measures').dimension_type.should == :measures 109 | end 110 | end 111 | 112 | describe "dimension hierarchies" do 113 | before(:all) do 114 | @cube = @olap.cube('Sales') 115 | end 116 | 117 | it "should get hierarchies" do 118 | hierarchies = @cube.dimension('Gender').hierarchies 119 | hierarchies.size.should == 1 120 | hierarchies[0].name.should == 'Gender' 121 | end 122 | 123 | it "should get hierarchy description" do 124 | hierarchies = @cube.dimension('Gender').hierarchies.first.description.should == 'Gender hierarchy description' 125 | end 126 | 127 | it "should get hierarchy names" do 128 | @cube.dimension('Time').hierarchy_names.should == ['Time', 'Time.Weekly'] 129 | end 130 | 131 | it "should get hierarchy by name" do 132 | @cube.dimension('Time').hierarchy('Time.Weekly').name.should == 'Time.Weekly' 133 | end 134 | 135 | it "should return nil when getting hierarchy with invalid name" do 136 | @cube.dimension('Time').hierarchy('invalid').should be_nil 137 | end 138 | 139 | it "should get default hierarchy" do 140 | @cube.dimension('Time').hierarchy.name.should == 'Time' 141 | end 142 | 143 | it "should get hierarchy levels" do 144 | @cube.dimension('Customers').hierarchy.levels.map(&:name).should == ['(All)', 'Country', 'State Province', 'City', 'Name'] 145 | end 146 | 147 | it "should get hierarchy level names" do 148 | @cube.dimension('Time').hierarchy.level_names.should == ['Year', 'Quarter', 'Month'] 149 | @cube.dimension('Customers').hierarchy.level_names.should == ['(All)', 'Country', 'State Province', 'City', 'Name'] 150 | end 151 | 152 | it "should get hierarchy level depths" do 153 | @cube.dimension('Customers').hierarchy.levels.map(&:depth).should == [0, 1, 2, 3, 4] 154 | end 155 | 156 | it "should get hierarchy level members count" do 157 | @cube.dimension('Gender').hierarchy.levels.map(&:members_count).should == [1, 2] 158 | end 159 | end 160 | 161 | describe "hierarchy values" do 162 | before(:all) do 163 | @cube = @olap.cube('Sales') 164 | end 165 | 166 | it "should get hierarchy all member" do 167 | @cube.dimension('Gender').hierarchy.has_all?.should be_true 168 | @cube.dimension('Gender').hierarchy.all_member_name.should == 'All Genders' 169 | end 170 | 171 | it "should not get all member for hierarchy without all member" do 172 | @cube.dimension('Time').hierarchy.has_all?.should be_false 173 | @cube.dimension('Time').hierarchy.all_member_name.should be_nil 174 | end 175 | 176 | it "should get hierarchy root members" do 177 | @cube.dimension('Gender').hierarchy.root_members.map(&:name).should == ['All Genders'] 178 | @cube.dimension('Gender').hierarchy.root_member_names.should == ['All Genders'] 179 | @cube.dimension('Time').hierarchy.root_members.map(&:name).should == ['2010', '2011'] 180 | @cube.dimension('Time').hierarchy.root_member_names.should == ['2010', '2011'] 181 | end 182 | 183 | it "should return child members for specified member" do 184 | @cube.dimension('Gender').hierarchy.child_names('All Genders').should == ['F', 'M'] 185 | @cube.dimension('Customers').hierarchy.child_names('USA', 'OR').should == 186 | ["Albany", "Beaverton", "Corvallis", "Lake Oswego", "Lebanon", "Milwaukie", 187 | "Oregon City", "Portland", "Salem", "W. Linn", "Woodburn"] 188 | end 189 | 190 | it "should return child members for hierarchy" do 191 | @cube.dimension('Gender').hierarchy.child_names.should == ['F', 'M'] 192 | end 193 | 194 | it "should not return child members for leaf member" do 195 | @cube.dimension('Gender').hierarchy.child_names('All Genders', 'F').should == [] 196 | end 197 | 198 | it "should return nil as child members if parent member not found" do 199 | @cube.dimension('Gender').hierarchy.child_names('N').should be_nil 200 | end 201 | 202 | end 203 | 204 | describe "level members" do 205 | before(:all) do 206 | @cube = @olap.cube('Sales') 207 | end 208 | 209 | it "should get level description" do 210 | @cube.dimension('Gender').hierarchy.level('Gender').description.should == 'Gender level description' 211 | end 212 | 213 | it "should return nil when getting level with invalid name" do 214 | @cube.dimension('Gender').hierarchy.level('invalid').should be_nil 215 | end 216 | 217 | it "should get primary hierarchy level members" do 218 | @cube.dimension('Customers').hierarchy.level('Country').members. 219 | map(&:name).should == ['Canada', 'Mexico', 'USA'] 220 | end 221 | 222 | it "should get secondary hierarchy level members" do 223 | @cube.dimension('Time').hierarchy('Time.Weekly').level('Year').members. 224 | map(&:name).should == ['2010', '2011'] 225 | end 226 | end 227 | 228 | describe "members" do 229 | before(:all) do 230 | @cube = @olap.cube('Sales') 231 | end 232 | 233 | it "should return member for specified full name" do 234 | @cube.member('[Gender].[All Genders]').name.should == 'All Genders' 235 | @cube.member('[Customers].[USA].[OR]').name.should == 'OR' 236 | end 237 | 238 | it "should not return member for invalid full name" do 239 | @cube.member('[Gender].[invalid]').should be_nil 240 | end 241 | 242 | it "should return child members for member" do 243 | @cube.member('[Gender].[All Genders]').children.map(&:name).should == ['F', 'M'] 244 | @cube.member('[Customers].[USA].[OR]').children.map(&:name).should == 245 | ["Albany", "Beaverton", "Corvallis", "Lake Oswego", "Lebanon", "Milwaukie", 246 | "Oregon City", "Portland", "Salem", "W. Linn", "Woodburn"] 247 | end 248 | 249 | it "should return empty children array if member does not have children" do 250 | @cube.member('[Gender].[All Genders].[F]').children.should be_empty 251 | end 252 | 253 | it "should return member depth" do 254 | @cube.member('[Customers].[All Customers]').depth.should == 0 255 | @cube.member('[Customers].[USA]').depth.should == 1 256 | @cube.member('[Customers].[USA].[CA]').depth.should == 2 257 | end 258 | 259 | it "should return descendants for member at specified level" do 260 | @cube.member('[Customers].[Mexico]').descendants_at_level('City').map(&:name).should == 261 | ["San Andres", "Santa Anita", "Santa Fe", "Tixapan", "Acapulco", "Guadalajara", 262 | "Mexico City", "Tlaxiaco", "La Cruz", "Orizaba", "Merida", "Camacho", "Hidalgo"] 263 | end 264 | 265 | it "should not return descendants for member when upper level specified" do 266 | @cube.member('[Customers].[Mexico].[DF]').descendants_at_level('Country').should be_nil 267 | end 268 | 269 | it "should be drillable when member has descendants" do 270 | @cube.member('[Customers].[USA]').should be_drillable 271 | end 272 | 273 | it "should not be drillable when member has no descendants" do 274 | @cube.member('[Gender].[F]').should_not be_drillable 275 | end 276 | 277 | it "should not be drillable when member is calculated" do 278 | @cube.member('[Customers].[Non-USA]').should_not be_drillable 279 | end 280 | 281 | it "should be calculated when member is calculated" do 282 | @cube.member('[Customers].[Non-USA]').should be_calculated 283 | end 284 | 285 | it "should not be calculated in query when calculated member defined in schema" do 286 | @cube.member('[Customers].[Non-USA]').should_not be_calculated_in_query 287 | end 288 | 289 | it "should not be calculated when normal member" do 290 | @cube.member('[Customers].[USA]').should_not be_calculated 291 | end 292 | 293 | it "should be all member when member is all member" do 294 | @cube.member('[Customers].[All Customers]').should be_all_member 295 | end 296 | 297 | it "should not be all member when member is not all member" do 298 | @cube.member('[Customers].[USA]').should_not be_all_member 299 | end 300 | 301 | it "should get dimension type of standard dimension member" do 302 | @cube.member('[Customers].[USA]').dimension_type.should == :standard 303 | end 304 | 305 | it "should get dimension type of measure" do 306 | @cube.member('[Measures].[Unit Sales]').dimension_type.should == :measures 307 | end 308 | 309 | it "should get dimension type of time dimension member" do 310 | @cube.member('[Time].[2011]').dimension_type.should == :time 311 | end 312 | 313 | it "should be visble when member is visible" do 314 | @cube.member('[Measures].[Store Sales]').should be_visible 315 | end 316 | 317 | it "should not be visble when member is not visible" do 318 | @cube.member('[Measures].[Store Cost]').should_not be_visible 319 | end 320 | end 321 | 322 | end 323 | -------------------------------------------------------------------------------- /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 sections chapters) 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 | raise ArgumentError, "cannot use #{method} method before axis or with_set method" unless @current_set 50 | raise ArgumentError, "specify list 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 | @current_set.replace [:#{method}, @current_set.clone, members] 53 | self 54 | end 55 | RUBY 56 | end 57 | 58 | def except(*axis_members) 59 | raise ArgumentError, "cannot use except method before axis or with_set method" unless @current_set 60 | raise ArgumentError, "specify list 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 | if [:crossjoin, :nonempty_crossjoin].include? @current_set[0] 63 | @current_set[2] = [:except, @current_set[2], members] 64 | else 65 | @current_set.replace [:except, @current_set.clone, members] 66 | end 67 | self 68 | end 69 | 70 | def nonempty 71 | raise ArgumentError, "cannot use nonempty method before axis method" unless @current_set 72 | @current_set.replace [:nonempty, @current_set.clone] 73 | self 74 | end 75 | 76 | def filter(condition, options={}) 77 | raise ArgumentError, "cannot use filter method before axis or with_set method" unless @current_set 78 | @current_set.replace [:filter, @current_set.clone, condition] 79 | @current_set << options[:as] if options[:as] 80 | self 81 | end 82 | 83 | def filter_nonempty 84 | raise ArgumentError, "cannot use filter_nonempty method before axis or with_set method" unless @current_set 85 | condition = "NOT ISEMPTY(S.CURRENT)" 86 | @current_set.replace [:filter, @current_set.clone, condition, 'S'] 87 | self 88 | end 89 | 90 | VALID_ORDERS = ['ASC', 'BASC', 'DESC', 'BDESC'] 91 | 92 | def order(expression, direction) 93 | raise ArgumentError, "cannot use order method before axis or with_set method" unless @current_set 94 | direction = direction.to_s.upcase 95 | raise ArgumentError, "invalid order direction #{direction.inspect}," << 96 | " should be one of #{VALID_ORDERS.inspect[1..-2]}" unless VALID_ORDERS.include?(direction) 97 | @current_set.replace [:order, @current_set.clone, expression, direction] 98 | self 99 | end 100 | 101 | %w(top bottom).each do |extreme| 102 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 103 | def #{extreme}_count(count, expression=nil) 104 | raise ArgumentError, "cannot use #{extreme}_count method before axis or with_set method" unless @current_set 105 | @current_set.replace [:#{extreme}_count, @current_set.clone, count] 106 | @current_set << expression if expression 107 | self 108 | end 109 | RUBY 110 | 111 | %w(percent sum).each do |extreme_name| 112 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 113 | def #{extreme}_#{extreme_name}(value, expression) 114 | raise ArgumentError, "cannot use #{extreme}_#{extreme_name} method before axis or with_set method" unless @current_set 115 | @current_set.replace [:#{extreme}_#{extreme_name}, @current_set.clone, value, expression] 116 | self 117 | end 118 | RUBY 119 | end 120 | end 121 | 122 | def hierarchize(order=nil, all=nil) 123 | raise ArgumentError, "cannot use hierarchize method before axis or with_set method" unless @current_set 124 | order = order && order.to_s.upcase 125 | raise ArgumentError, "invalid hierarchize order #{order.inspect}" unless order.nil? || order == 'POST' 126 | if all.nil? && [:crossjoin, :nonempty_crossjoin].include?(@current_set[0]) 127 | @current_set[2] = [:hierarchize, @current_set[2]] 128 | @current_set[2] << order if order 129 | else 130 | @current_set.replace [:hierarchize, @current_set.clone] 131 | @current_set << order if order 132 | end 133 | self 134 | end 135 | 136 | def hierarchize_all(order=nil) 137 | hierarchize(order, :all) 138 | end 139 | 140 | # Add new WHERE condition to query 141 | # or return array of existing conditions if no arguments specified 142 | def where(*members) 143 | if members.empty? 144 | @where 145 | else 146 | @current_set = @where 147 | if members.length == 1 && members[0].is_a?(Array) 148 | @where.concat(members[0]) 149 | else 150 | @where.concat(members) 151 | end 152 | self 153 | end 154 | end 155 | 156 | # Add definition of calculated member 157 | def with_member(member_name) 158 | @with << [:member, member_name] 159 | @current_set = nil 160 | self 161 | end 162 | 163 | # Add definition of named_set 164 | def with_set(set_name) 165 | @current_set = [] 166 | @with << [:set, set_name, @current_set] 167 | self 168 | end 169 | 170 | # return array of member and set definitions 171 | def with 172 | @with 173 | end 174 | 175 | # Add definition to calculated member or to named set 176 | def as(*params) 177 | # definition of named set 178 | if @current_set 179 | if params.empty? 180 | raise ArgumentError, "named set cannot be empty" 181 | else 182 | raise ArgumentError, "cannot use 'as' method before with_set method" unless @current_set.empty? 183 | if params.length == 1 && params[0].is_a?(Array) 184 | @current_set.concat(params[0]) 185 | else 186 | @current_set.concat(params) 187 | end 188 | end 189 | # definition of calculated member 190 | else 191 | member_definition = @with.last 192 | options = params.last.is_a?(Hash) ? params.pop : nil 193 | raise ArgumentError, "cannot use 'as' method before with_member method" unless member_definition && 194 | member_definition[0] == :member && member_definition.length == 2 195 | raise ArgumentError, "calculated member definition should be single expression" unless params.length == 1 196 | member_definition << params[0] 197 | member_definition << options if options 198 | end 199 | self 200 | end 201 | 202 | def to_mdx 203 | mdx = "" 204 | mdx << "WITH #{with_to_mdx}\n" unless @with.empty? 205 | mdx << "SELECT #{axis_to_mdx}\n" 206 | mdx << "FROM #{from_to_mdx}" 207 | mdx << "\nWHERE #{where_to_mdx}" unless @where.empty? 208 | mdx 209 | end 210 | 211 | def execute 212 | Error.wrap_native_exception do 213 | @connection.execute to_mdx 214 | end 215 | end 216 | 217 | private 218 | 219 | # FIXME: keep original order of WITH MEMBER and WITH SET defitions 220 | def with_to_mdx 221 | @with.map do |definition| 222 | case definition[0] 223 | when :member 224 | member_name = definition[1] 225 | expression = definition[2] 226 | options = definition[3] 227 | options_string = '' 228 | options && options.each do |option, value| 229 | options_string << ", #{option.to_s.upcase} = #{quote_value(value)}" 230 | end 231 | "MEMBER #{member_name} AS #{quote_value(expression)}#{options_string}" 232 | when :set 233 | set_name = definition[1] 234 | set_members = definition[2] 235 | "SET #{set_name} AS #{quote_value(members_to_mdx(set_members))}" 236 | end 237 | end.join("\n") 238 | end 239 | 240 | def axis_to_mdx 241 | mdx = "" 242 | @axes.each_with_index do |axis_members, i| 243 | axis_name = AXIS_ALIASES[i] ? AXIS_ALIASES[i].upcase : "AXIS(#{i})" 244 | mdx << ",\n" if i > 0 245 | mdx << members_to_mdx(axis_members) << " ON " << axis_name 246 | end 247 | mdx 248 | end 249 | 250 | MDX_FUNCTIONS = { 251 | :top_count => 'TOPCOUNT', 252 | :top_percent => 'TOPPERCENT', 253 | :top_sum => 'TOPSUM', 254 | :bottom_count => 'BOTTOMCOUNT', 255 | :bottom_percent => 'BOTTOMPERCENT', 256 | :bottom_sum => 'BOTTOMSUM' 257 | } 258 | 259 | def members_to_mdx(members) 260 | # if only one member which does not end with ] 261 | # then assume it is expression which returns set 262 | # TODO: maybe always include also single expressions in {...} to avoid some edge cases? 263 | if members.length == 1 && members[0][-1,1] != ']' 264 | members[0] 265 | elsif members[0].is_a?(Symbol) 266 | case members[0] 267 | when :crossjoin 268 | "CROSSJOIN(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 269 | when :nonempty_crossjoin 270 | "NONEMPTYCROSSJOIN(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 271 | when :except 272 | "EXCEPT(#{members_to_mdx(members[1])}, #{members_to_mdx(members[2])})" 273 | when :nonempty 274 | "NON EMPTY #{members_to_mdx(members[1])}" 275 | when :filter 276 | as_alias = members[3] ? " AS #{members[3]}" : nil 277 | "FILTER(#{members_to_mdx(members[1])}#{as_alias}, #{members[2]})" 278 | when :order 279 | "ORDER(#{members_to_mdx(members[1])}, #{expression_to_mdx(members[2])}, #{members[3]})" 280 | when :top_count, :bottom_count 281 | mdx = "#{MDX_FUNCTIONS[members[0]]}(#{members_to_mdx(members[1])}, #{members[2]}" 282 | mdx << (members[3] ? ", #{expression_to_mdx(members[3])})" : ")") 283 | when :top_percent, :top_sum, :bottom_percent, :bottom_sum 284 | "#{MDX_FUNCTIONS[members[0]]}(#{members_to_mdx(members[1])}, #{members[2]}, #{expression_to_mdx(members[3])})" 285 | when :hierarchize 286 | "HIERARCHIZE(#{members_to_mdx(members[1])}#{members[2] && ", #{members[2]}"})" 287 | else 288 | raise ArgumentError, "Cannot generate MDX for invalid set operation #{members[0].inspect}" 289 | end 290 | else 291 | "{#{members.join(', ')}}" 292 | end 293 | end 294 | 295 | def expression_to_mdx(expression) 296 | expression.is_a?(Array) ? "(#{expression.join(', ')})" : expression 297 | end 298 | 299 | def from_to_mdx 300 | "[#{@cube_name}]" 301 | end 302 | 303 | def where_to_mdx 304 | # generate set MDX expression 305 | if @where[0].is_a?(Symbol) || 306 | @where.length > 1 && @where.map{|full_name| extract_dimension_name(full_name)}.uniq.length == 1 307 | members_to_mdx(@where) 308 | # generate tuple MDX expression 309 | else 310 | where_to_mdx_tuple 311 | end 312 | end 313 | 314 | def where_to_mdx_tuple 315 | mdx = '(' 316 | mdx << @where.map do |condition| 317 | condition 318 | end.join(', ') 319 | mdx << ')' 320 | end 321 | 322 | def quote_value(value) 323 | case value 324 | when String 325 | "'#{value.gsub("'", "''")}'" 326 | when TrueClass, FalseClass 327 | value ? 'TRUE' : 'FALSE' 328 | when NilClass 329 | 'NULL' 330 | else 331 | "#{value}" 332 | end 333 | end 334 | 335 | def extract_dimension_name(full_name) 336 | if full_name =~ /\A[^\[]*\[([^\]]+)\]/ 337 | $1 338 | end 339 | end 340 | end 341 | end 342 | end 343 | -------------------------------------------------------------------------------- /LICENSE-Mondrian.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Eclipse Public License - Version 1.0 9 | 26 | 27 | 28 | 29 |

Eclipse Public License - v 1.0

30 | 31 |

THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 32 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR 33 | DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS 34 | AGREEMENT.

35 | 36 |

1. DEFINITIONS

37 | 38 |

"Contribution" means:

39 | 40 |

a) in the case of the initial Contributor, the initial 41 | code and documentation distributed under this Agreement, and

42 |

b) in the case of each subsequent Contributor:

43 |

i) changes to the Program, and

44 |

ii) additions to the Program;

45 |

where such changes and/or additions to the Program 46 | originate from and are distributed by that particular Contributor. A 47 | Contribution 'originates' from a Contributor if it was added to the 48 | Program by such Contributor itself or anyone acting on such 49 | Contributor's behalf. Contributions do not include additions to the 50 | Program which: (i) are separate modules of software distributed in 51 | conjunction with the Program under their own license agreement, and (ii) 52 | are not derivative works of the Program.

53 | 54 |

"Contributor" means any person or entity that distributes 55 | the Program.

56 | 57 |

"Licensed Patents" mean patent claims licensable by a 58 | Contributor which are necessarily infringed by the use or sale of its 59 | Contribution alone or when combined with the Program.

60 | 61 |

"Program" means the Contributions distributed in accordance 62 | with this Agreement.

63 | 64 |

"Recipient" means anyone who receives the Program under 65 | this Agreement, including all Contributors.

66 | 67 |

2. GRANT OF RIGHTS

68 | 69 |

a) Subject to the terms of this Agreement, each 70 | Contributor hereby grants Recipient a non-exclusive, worldwide, 71 | royalty-free copyright license to reproduce, prepare derivative works 72 | of, publicly display, publicly perform, distribute and sublicense the 73 | Contribution of such Contributor, if any, and such derivative works, in 74 | source code and object code form.

75 | 76 |

b) Subject to the terms of this Agreement, each 77 | Contributor hereby grants Recipient a non-exclusive, worldwide, 78 | royalty-free patent license under Licensed Patents to make, use, sell, 79 | offer to sell, import and otherwise transfer the Contribution of such 80 | Contributor, if any, in source code and object code form. This patent 81 | license shall apply to the combination of the Contribution and the 82 | Program if, at the time the Contribution is added by the Contributor, 83 | such addition of the Contribution causes such combination to be covered 84 | by the Licensed Patents. The patent license shall not apply to any other 85 | combinations which include the Contribution. No hardware per se is 86 | licensed hereunder.

87 | 88 |

c) Recipient understands that although each Contributor 89 | grants the licenses to its Contributions set forth herein, no assurances 90 | are provided by any Contributor that the Program does not infringe the 91 | patent or other intellectual property rights of any other entity. Each 92 | Contributor disclaims any liability to Recipient for claims brought by 93 | any other entity based on infringement of intellectual property rights 94 | or otherwise. As a condition to exercising the rights and licenses 95 | granted hereunder, each Recipient hereby assumes sole responsibility to 96 | secure any other intellectual property rights needed, if any. For 97 | example, if a third party patent license is required to allow Recipient 98 | to distribute the Program, it is Recipient's responsibility to acquire 99 | that license before distributing the Program.

100 | 101 |

d) Each Contributor represents that to its knowledge it 102 | has sufficient copyright rights in its Contribution, if any, to grant 103 | the copyright license set forth in this Agreement.

104 | 105 |

3. REQUIREMENTS

106 | 107 |

A Contributor may choose to distribute the Program in object code 108 | form under its own license agreement, provided that:

109 | 110 |

a) it complies with the terms and conditions of this 111 | Agreement; and

112 | 113 |

b) its license agreement:

114 | 115 |

i) effectively disclaims on behalf of all Contributors 116 | all warranties and conditions, express and implied, including warranties 117 | or conditions of title and non-infringement, and implied warranties or 118 | conditions of merchantability and fitness for a particular purpose;

119 | 120 |

ii) effectively excludes on behalf of all Contributors 121 | all liability for damages, including direct, indirect, special, 122 | incidental and consequential damages, such as lost profits;

123 | 124 |

iii) states that any provisions which differ from this 125 | Agreement are offered by that Contributor alone and not by any other 126 | party; and

127 | 128 |

iv) states that source code for the Program is available 129 | from such Contributor, and informs licensees how to obtain it in a 130 | reasonable manner on or through a medium customarily used for software 131 | exchange.

132 | 133 |

When the Program is made available in source code form:

134 | 135 |

a) it must be made available under this Agreement; and

136 | 137 |

b) a copy of this Agreement must be included with each 138 | copy of the Program.

139 | 140 |

Contributors may not remove or alter any copyright notices contained 141 | within the Program.

142 | 143 |

Each Contributor must identify itself as the originator of its 144 | Contribution, if any, in a manner that reasonably allows subsequent 145 | Recipients to identify the originator of the Contribution.

146 | 147 |

4. COMMERCIAL DISTRIBUTION

148 | 149 |

Commercial distributors of software may accept certain 150 | responsibilities with respect to end users, business partners and the 151 | like. While this license is intended to facilitate the commercial use of 152 | the Program, the Contributor who includes the Program in a commercial 153 | product offering should do so in a manner which does not create 154 | potential liability for other Contributors. Therefore, if a Contributor 155 | includes the Program in a commercial product offering, such Contributor 156 | ("Commercial Contributor") hereby agrees to defend and 157 | indemnify every other Contributor ("Indemnified Contributor") 158 | against any losses, damages and costs (collectively "Losses") 159 | arising from claims, lawsuits and other legal actions brought by a third 160 | party against the Indemnified Contributor to the extent caused by the 161 | acts or omissions of such Commercial Contributor in connection with its 162 | distribution of the Program in a commercial product offering. The 163 | obligations in this section do not apply to any claims or Losses 164 | relating to any actual or alleged intellectual property infringement. In 165 | order to qualify, an Indemnified Contributor must: a) promptly notify 166 | the Commercial Contributor in writing of such claim, and b) allow the 167 | Commercial Contributor to control, and cooperate with the Commercial 168 | Contributor in, the defense and any related settlement negotiations. The 169 | Indemnified Contributor may participate in any such claim at its own 170 | expense.

171 | 172 |

For example, a Contributor might include the Program in a commercial 173 | product offering, Product X. That Contributor is then a Commercial 174 | Contributor. If that Commercial Contributor then makes performance 175 | claims, or offers warranties related to Product X, those performance 176 | claims and warranties are such Commercial Contributor's responsibility 177 | alone. Under this section, the Commercial Contributor would have to 178 | defend claims against the other Contributors related to those 179 | performance claims and warranties, and if a court requires any other 180 | Contributor to pay any damages as a result, the Commercial Contributor 181 | must pay those damages.

182 | 183 |

5. NO WARRANTY

184 | 185 |

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS 186 | PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS 187 | OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, 188 | ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY 189 | OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely 190 | responsible for determining the appropriateness of using and 191 | distributing the Program and assumes all risks associated with its 192 | exercise of rights under this Agreement , including but not limited to 193 | the risks and costs of program errors, compliance with applicable laws, 194 | damage to or loss of data, programs or equipment, and unavailability or 195 | interruption of operations.

196 | 197 |

6. DISCLAIMER OF LIABILITY

198 | 199 |

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT 200 | NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, 201 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING 202 | WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF 203 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 204 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR 205 | DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED 206 | HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

207 | 208 |

7. GENERAL

209 | 210 |

If any provision of this Agreement is invalid or unenforceable under 211 | applicable law, it shall not affect the validity or enforceability of 212 | the remainder of the terms of this Agreement, and without further action 213 | by the parties hereto, such provision shall be reformed to the minimum 214 | extent necessary to make such provision valid and enforceable.

215 | 216 |

If Recipient institutes patent litigation against any entity 217 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 218 | Program itself (excluding combinations of the Program with other 219 | software or hardware) infringes such Recipient's patent(s), then such 220 | Recipient's rights granted under Section 2(b) shall terminate as of the 221 | date such litigation is filed.

222 | 223 |

All Recipient's rights under this Agreement shall terminate if it 224 | fails to comply with any of the material terms or conditions of this 225 | Agreement and does not cure such failure in a reasonable period of time 226 | after becoming aware of such noncompliance. If all Recipient's rights 227 | under this Agreement terminate, Recipient agrees to cease use and 228 | distribution of the Program as soon as reasonably practicable. However, 229 | Recipient's obligations under this Agreement and any licenses granted by 230 | Recipient relating to the Program shall continue and survive.

231 | 232 |

Everyone is permitted to copy and distribute copies of this 233 | Agreement, but in order to avoid inconsistency the Agreement is 234 | copyrighted and may only be modified in the following manner. The 235 | Agreement Steward reserves the right to publish new versions (including 236 | revisions) of this Agreement from time to time. No one other than the 237 | Agreement Steward has the right to modify this Agreement. The Eclipse 238 | Foundation is the initial Agreement Steward. The Eclipse Foundation may 239 | assign the responsibility to serve as the Agreement Steward to a 240 | suitable separate entity. Each new version of the Agreement will be 241 | given a distinguishing version number. The Program (including 242 | Contributions) may always be distributed subject to the version of the 243 | Agreement under which it was received. In addition, after a new version 244 | of the Agreement is published, Contributor may elect to distribute the 245 | Program (including its Contributions) under the new version. Except as 246 | expressly stated in Sections 2(a) and 2(b) above, Recipient receives no 247 | rights or licenses to the intellectual property of any Contributor under 248 | this Agreement, whether expressly, by implication, estoppel or 249 | otherwise. All rights in the Program not expressly granted under this 250 | Agreement are reserved.

251 | 252 |

This Agreement is governed by the laws of the State of New York and 253 | the intellectual property laws of the United States of America. No party 254 | to this Agreement will bring a legal action under this Agreement more 255 | than one year after the cause of action arose. Each party waives its 256 | rights to a jury trial in any resulting litigation.

257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mondrian-olap 2 | ============= 3 | 4 | JRuby gem for performing multidimensional queries of relational database data using Mondrian OLAP Java library. 5 | 6 | DESCRIPTION 7 | ----------- 8 | 9 | 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. 10 | 11 | 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. 12 | 13 | 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. 14 | 15 | mondrian-olap is used in [eazyBI data analysis and reporting web application](https://eazybi.com). [eazyBI remote setup](https://eazybi.com/help/remote-setup) 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). 16 | 17 | USAGE 18 | ----- 19 | 20 | ### Schema definition 21 | 22 | At first you need to define OLAP schema mapping to relational database schema tables and columns. OLAP schema consists of: 23 | 24 | * Cubes 25 | 26 | 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. 27 | 28 | * Dimensions 29 | 30 | Dimension can be used in one cube (private) or in many cubes (shared). In relational database dimensions are stored in dimension tables. 31 | 32 | * Hierarchies and levels 33 | 34 | 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. 35 | 36 | * Members 37 | 38 | Dimension hierarchy level values are called members. 39 | 40 | * Measures 41 | 42 | 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. 43 | 44 | * Calculated measures 45 | 46 | Calculated measures are not stored in database but calculated using specified formula from other measures. 47 | 48 | Read more about about [defining Mondrian OLAP schema](http://mondrian.pentaho.com/documentation/schema.php). 49 | 50 | Here is example how to define OLAP schema and its mapping to relational database tables and columns using mondrian-olap: 51 | 52 | ```ruby 53 | require "rubygems" 54 | require "mondrian-olap" 55 | 56 | schema = Mondrian::OLAP::Schema.define do 57 | cube 'Sales' do 58 | table 'sales' 59 | dimension 'Customers', :foreign_key => 'customer_id' do 60 | hierarchy :has_all => true, :all_member_name => 'All Customers', :primary_key => 'id' do 61 | table 'customers' 62 | level 'Country', :column => 'country', :unique_members => true 63 | level 'State Province', :column => 'state_province', :unique_members => true 64 | level 'City', :column => 'city', :unique_members => false 65 | level 'Name', :column => 'fullname', :unique_members => true 66 | end 67 | end 68 | dimension 'Products', :foreign_key => 'product_id' do 69 | hierarchy :has_all => true, :all_member_name => 'All Products', 70 | :primary_key => 'id', :primary_key_table => 'products' do 71 | join :left_key => 'product_class_id', :right_key => 'id' do 72 | table 'products' 73 | table 'product_classes' 74 | end 75 | level 'Product Family', :table => 'product_classes', :column => 'product_family', :unique_members => true 76 | level 'Brand Name', :table => 'products', :column => 'brand_name', :unique_members => false 77 | level 'Product Name', :table => 'products', :column => 'product_name', :unique_members => true 78 | end 79 | end 80 | dimension 'Time', :foreign_key => 'time_id', :type => 'TimeDimension' do 81 | hierarchy :has_all => false, :primary_key => 'id' do 82 | table 'time' 83 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 84 | level 'Quarter', :column => 'quarter', :unique_members => false, :level_type => 'TimeQuarters' 85 | level 'Month', :column => 'month_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeMonths' 86 | end 87 | hierarchy 'Weekly', :has_all => false, :primary_key => 'id' do 88 | table 'time' 89 | level 'Year', :column => 'the_year', :type => 'Numeric', :unique_members => true, :level_type => 'TimeYears' 90 | level 'Week', :column => 'week_of_year', :type => 'Numeric', :unique_members => false, :level_type => 'TimeWeeks' 91 | end 92 | end 93 | measure 'Unit Sales', :column => 'unit_sales', :aggregator => 'sum' 94 | measure 'Store Sales', :column => 'store_sales', :aggregator => 'sum' 95 | end 96 | end 97 | ``` 98 | 99 | ### Connection creation 100 | 101 | 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: 102 | 103 | ```ruby 104 | require "jdbc/mysql" 105 | 106 | olap = Mondrian::OLAP::Connection.create( 107 | :driver => 'mysql', 108 | :host => 'localhost, 109 | :database => 'mondrian_test', 110 | :username => 'mondrian_user', 111 | :password => 'secret', 112 | :schema => schema 113 | ) 114 | ``` 115 | 116 | ### MDX queries 117 | 118 | Mondrian OLAP provides MDX query language. [Read more about MDX](http://mondrian.pentaho.com/documentation/mdx.php). 119 | 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": 120 | 121 | ```ruby 122 | result = olap.execute <<-MDX 123 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 124 | {[Products].children} ON ROWS 125 | FROM [Sales] 126 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 127 | MDX 128 | ``` 129 | 130 | which would correspond to the following SQL query: 131 | 132 | SELECT SUM(unit_sales) unit_sales_sum, SUM(store_sales) store_sales_sum 133 | FROM sales 134 | LEFT JOIN products ON sales.product_id = products.id 135 | LEFT JOIN product_classes ON products.product_class_id = product_classes.id 136 | LEFT JOIN time ON sales.time_id = time.id 137 | LEFT JOIN customers ON sales.customer_id = customers.id 138 | WHERE time.the_year = 2010 AND time.quarter = 'Q1' 139 | AND customers.country = 'USA' AND customers.state_province = 'CA' 140 | GROUP BY product_classes.product_family 141 | ORDER BY product_classes.product_family 142 | 143 | and then get axis and cells of result object: 144 | 145 | ```ruby 146 | result.axes_count # => 2 147 | result.column_names # => ["Unit Sales", "Store Sales"] 148 | result.column_full_names # => ["[Measures].[Unit Sales]", "[Measures].[Store Sales]"] 149 | result.row_names # => e.g. ["Drink", "Food", "Non-Consumable"] 150 | result.row_full_names # => e.g. ["[Products].[Drink]", "[Products].[Food]", "[Products].[Non-Consumable]"] 151 | result.values # => [[..., ...], [..., ...], [..., ...]] 152 | # (three rows, each row containing value for "unit sales" and "store sales") 153 | ``` 154 | 155 | ### Query builder methods 156 | 157 | MDX queries could be built and executed also using Ruby methods in a similar way as ActiveRecord/Arel queries are made. 158 | Previous MDX query can be executed as: 159 | 160 | ```ruby 161 | olap.from('Sales'). 162 | columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 163 | rows('[Products].children'). 164 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 165 | execute 166 | ``` 167 | 168 | 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": 169 | 170 | ```ruby 171 | olap.from('Sales'). 172 | with_member('[Measures].[ProfitPct]'). 173 | as('Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 174 | :format_string => 'Percent'). 175 | columns('[Measures].[Store Sales]', '[Measures].[ProfitPct]'). 176 | rows('[Products].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 177 | top_count(50, '[Measures].[Store Sales]'). 178 | where('[Time].[2010].[Q1]'). 179 | execute 180 | ``` 181 | 182 | See more examples of queries in `spec/query_spec.rb`. 183 | 184 | 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. 185 | 186 | ### Cube dimension and member queries 187 | 188 | mondrian-olap provides also methods for querying dimensions and members: 189 | 190 | ```ruby 191 | cube = olap.cube('Sales') 192 | cube.dimension_names # => ['Measures', 'Customers', 'Products', 'Time'] 193 | cube.dimensions # => array of dimension objects 194 | cube.dimension('Customers') # => customers dimension object 195 | cube.dimension('Time').hierarchy_names # => ['Time', 'Time.Weekly'] 196 | cube.dimension('Time').hierarchies # => array of hierarchy objects 197 | cube.dimension('Customers').hierarchy # => default customers dimension hierarchy 198 | cube.dimension('Customers').hierarchy.level_names 199 | # => ['(All)', 'Country', 'State Province', 'City', 'Name'] 200 | cube.dimension('Customers').hierarchy.levels 201 | # => array of hierarchy level objects 202 | cube.dimension('Customers').hierarchy.level('Country').members 203 | # => array of all level members 204 | cube.member('[Customers].[USA].[CA]') # => lookup member by full name 205 | cube.member('[Customers].[USA].[CA]').children 206 | # => get all children of member in deeper hierarchy level 207 | cube.member('[Customers].[USA]').descendants_at_level('City') 208 | # => get all descendants of member in specified hierarchy level 209 | ``` 210 | 211 | See more examples of dimension and member queries in `spec/cube_spec.rb`. 212 | 213 | ### User defined MDX functions 214 | 215 | You can define new MDX functions using JavaScript, CoffeeScript or Ruby language that you can later use 216 | either in calculated member formulas or in MDX queries. Here are examples of user defined functions in Ruby: 217 | 218 | ```ruby 219 | schema = Mondrian::OLAP::Schema.define do 220 | # ... cube definitions ... 221 | user_defined_function 'Factorial' do 222 | ruby do 223 | parameters :numeric 224 | returns :numeric 225 | def call(n) 226 | n <= 1 ? 1 : n * call(n - 1) 227 | end 228 | end 229 | end 230 | user_defined_function 'UpperName' do 231 | ruby do 232 | parameters :member 233 | returns :string 234 | syntax :property 235 | def call(member) 236 | member.getName.upcase 237 | end 238 | end 239 | end 240 | end 241 | ``` 242 | 243 | See more examples of user defined functions in `spec/schema_definition_spec.rb`. 244 | 245 | ### Data access roles 246 | 247 | In schema you can define data access roles which can be selected for connection and which will limit access just to 248 | subset of measures and dimension members. Here is example of data access role definition: 249 | 250 | ```ruby 251 | schema = Mondrian::OLAP::Schema.define do 252 | # ... cube definitions ... 253 | role 'California manager' do 254 | schema_grant :access => 'none' do 255 | cube_grant :cube => 'Sales', :access => 'all' do 256 | dimension_grant :dimension => '[Measures]', :access => 'all' 257 | hierarchy_grant :hierarchy => '[Customers]', :access => 'custom', 258 | :top_level => '[Customers].[State Province]', :bottom_level => '[Customers].[City]' do 259 | member_grant :member => '[Customers].[USA].[CA]', :access => 'all' 260 | member_grant :member => '[Customers].[USA].[CA].[Los Angeles]', :access => 'none' 261 | end 262 | end 263 | end 264 | end 265 | end 266 | ``` 267 | 268 | See more examples of data access roles in `spec/connection_role_spec.rb`. 269 | 270 | REQUIREMENTS 271 | ------------ 272 | 273 | mondrian-olap gem is compatible with JRuby versions 1.6 and 1.7 and Java 6 and 7 VM. mondrian-olap works only with JRuby and not with other Ruby implementations as it includes Mondrian OLAP Java libraries. 274 | 275 | mondrian-olap currently supports MySQL, PostgreSQL, Oracle, LucidDB and Microsoft SQL Server databases. When using MySQL, PostgreSQL or LucidDB databases then install jdbc-mysql, jdbc-postgres or jdbc-luciddb gem and require "jdbc/mysql", "jdbc/postgres" or "jdbc/luciddb" to load the corresponding JDBC database driver. When using Oracle then include Oracle JDBC driver (`ojdbc6.jar` for Java 6) in `CLASSPATH` or copy to `JRUBY_HOME/lib` or require it in application manually. When using SQL Server you can choose between the jTDS or Microsoft JDBC drivers. If you use jTDS require "jdbc/jtds". If you use the Microsoft JDBC driver include `sqljdbc.jar` or `sqljdbc4.jar` in `CLASSPATH` or copy to `JRUBY_HOME/lib` or require it in application manually. 276 | 277 | INSTALL 278 | ------- 279 | 280 | Install gem with: 281 | 282 | gem install mondrian-olap 283 | 284 | or include in your project's Gemfile: 285 | 286 | gem "mondrian-olap" 287 | 288 | LINKS 289 | ----- 290 | 291 | * Source code: http://github.com/rsim/mondrian-olap 292 | * Bug reports / Feature requests: http://github.com/rsim/mondrian-olap/issues 293 | * General discussions and questions at: http://groups.google.com/group/mondrian-olap 294 | * mondrian-olap demo Rails application: https://github.com/rsim/mondrian_demo 295 | 296 | LICENSE 297 | ------- 298 | 299 | mondrian-olap is released under the terms of MIT license; see LICENSE.txt. 300 | 301 | Mondrian OLAP Engine is released under the terms of the Eclipse Public 302 | License v1.0 (EPL); see LICENSE-Mondrian.html. 303 | -------------------------------------------------------------------------------- /lib/mondrian/olap/schema.rb: -------------------------------------------------------------------------------- 1 | require 'mondrian/olap/schema_element' 2 | 3 | module Mondrian 4 | module OLAP 5 | # See http://mondrian.pentaho.com/documentation/schema.php for more detailed description 6 | # of Mondrian Schema elements. 7 | class Schema < SchemaElement 8 | def initialize(name = nil, attributes = {}, &block) 9 | name, attributes = self.class.pre_process_arguments(name, attributes) 10 | pre_process_attributes(attributes) 11 | super(name, attributes, &block) 12 | end 13 | 14 | def self.define(name = nil, attributes = {}, &block) 15 | name, attributes = pre_process_arguments(name, attributes) 16 | new(name || 'default', attributes, &block) 17 | end 18 | 19 | def define(name = nil, attributes = {}, &block) 20 | name, attributes = self.class.pre_process_arguments(name, attributes) 21 | pre_process_attributes(attributes) 22 | @attributes[:name] = name || @attributes[:name] || 'default' # otherwise connection with empty name fails 23 | instance_eval(&block) if block 24 | self 25 | end 26 | 27 | def include_schema(shared_schema) 28 | shared_schema.class.elements.each do |element| 29 | instance_variable_get("@#{pluralize(element)}").concat shared_schema.send(pluralize(element)) 30 | end 31 | end 32 | 33 | private 34 | 35 | def self.pre_process_arguments(name, attributes) 36 | # if is called just with attributes hash and without name 37 | if name.is_a?(Hash) && attributes.empty? 38 | attributes = name 39 | name = nil 40 | end 41 | [name, attributes] 42 | end 43 | 44 | def pre_process_attributes(attributes) 45 | unless attributes[:upcase_data_dictionary].nil? 46 | @upcase_data_dictionary = attributes.delete(:upcase_data_dictionary) 47 | end 48 | end 49 | 50 | public 51 | 52 | attributes :name, :description 53 | elements :cube, :role, :user_defined_function 54 | 55 | class Cube < SchemaElement 56 | attributes :name, :description, 57 | # The name of the measure that would be taken as the default measure of the cube. 58 | :default_measure, 59 | # Should the Fact table data for this Cube be cached by Mondrian or not. 60 | # The default action is to cache the data. 61 | :cache, 62 | # Whether element is enabled - if true, then the Cube is realized otherwise it is ignored. 63 | :enabled 64 | # always render xml fragment as the first element in XML output (by default it is added at the end) 65 | elements :xml, :table, :view, :dimension, :measure, :calculated_member 66 | end 67 | 68 | class Table < SchemaElement 69 | attributes :name, :schema, # Optional qualifier for table. 70 | # Alias to be used with this table when it is used to form queries. 71 | # If not specified, defaults to the table name, but in any case, must be unique within the schema. 72 | # (You can use the same table in different hierarchies, but it must have different aliases.) 73 | :alias 74 | data_dictionary_names :name, :schema, :alias # values in XML will be uppercased when using Oracle driver 75 | elements :agg_exclude, :agg_name, :agg_pattern, :sql 76 | end 77 | 78 | class View < SchemaElement 79 | attributes :alias 80 | data_dictionary_names :alias 81 | # Defines a "table" using SQL query which can have different variants for different underlying databases 82 | elements :sql 83 | end 84 | 85 | class Dimension < SchemaElement 86 | attributes :name, :description, 87 | # The dimension's type may be one of "Standard" or "Time". 88 | # A time dimension will allow the use of the MDX time functions (WTD, YTD, QTD, etc.). 89 | # Use a standard dimension if the dimension is not a time-related dimension. 90 | # The default value is "Standard". 91 | :type, 92 | # The name of the column in the fact table which joins to the leaf level of this dimension. 93 | # Required in a private Dimension or a DimensionUsage, but not in a public Dimension. 94 | :foreign_key 95 | data_dictionary_names :foreign_key # values in XML will be uppercased when using Oracle driver 96 | elements :hierarchy 97 | end 98 | 99 | class Hierarchy < SchemaElement 100 | attributes :name, :description, 101 | # Whether this hierarchy has an 'all' member. 102 | :has_all, 103 | # Name of the 'all' member. If this attribute is not specified, 104 | # the all member is named 'All hierarchyName', for example, 'All Store'. 105 | :all_member_name, 106 | # Name of the 'all' level. If this attribute is not specified, 107 | # the all member is named '(All)'. 108 | :all_level_name, 109 | # The name of the column which identifies members, and which is referenced by rows in the fact table. 110 | # If not specified, the key of the lowest level is used. See also Dimension foreign_key. 111 | :primary_key, 112 | # The name of the table which contains primary_key. 113 | # If the hierarchy has only one table, defaults to that; it is required. 114 | :primary_key_table, 115 | # Should be set to the level (if such a level exists) at which depth it is known 116 | # that all members have entirely unique rows, allowing SQL GROUP BY clauses to be completely eliminated from the query. 117 | :unique_key_level_name 118 | data_dictionary_names :primary_key, :primary_key_table # values in XML will be uppercased when using Oracle driver 119 | elements :table, :join, :view, :property, :level 120 | end 121 | 122 | class Join < SchemaElement 123 | attributes :left_key, :right_key, :left_alias, :right_alias 124 | data_dictionary_names :left_key, :right_key, :left_alias, :right_alias # values in XML will be uppercased when using Oracle driver 125 | elements :table, :join 126 | end 127 | 128 | class Level < SchemaElement 129 | attributes :name, :description, 130 | # The name of the table that the column comes from. 131 | # If this hierarchy is based upon just one table, defaults to the name of that table; 132 | # otherwise, it is required. 133 | :table, 134 | # The name of the column which holds the unique identifier of this level. 135 | :column, 136 | # The name of the column which holds the user identifier of this level. 137 | :name_column, 138 | # The name of the column which holds member ordinals. 139 | # If this column is not specified, the key column is used for ordering. 140 | :ordinal_column, 141 | # The name of the column which references the parent member in a parent-child hierarchy. 142 | :parent_column, 143 | # Value which identifies null parents in a parent-child hierarchy. 144 | # Typical values are 'NULL' and '0'. 145 | :null_parent_value, 146 | # Indicates the type of this level's key column: 147 | # String, Numeric, Integer, Boolean, Date, Time or Timestamp. 148 | # When generating SQL statements, Mondrian encloses values for String columns in quotation marks, 149 | # but leaves values for Integer and Numeric columns un-quoted. 150 | # Date, Time, and Timestamp values are quoted according to the SQL dialect. 151 | # For a SQL-compliant dialect, the values appear prefixed by their typename, 152 | # for example, "DATE '2006-06-01'". 153 | # Default value: 'String' 154 | :type, 155 | # Whether members are unique across all parents. 156 | # For example, zipcodes are unique across all states. 157 | # The first level's members are always unique. 158 | # Default value: false 159 | :unique_members, 160 | # Whether this is a regular or a time-related level. 161 | # The value makes a difference to time-related functions such as YTD (year-to-date). 162 | # Default value: 'Regular' 163 | :level_type, 164 | # Condition which determines whether a member of this level is hidden. 165 | # If a hierarchy has one or more levels with hidden members, 166 | # then it is possible that not all leaf members are the same distance from the root, 167 | # and it is termed a ragged hierarchy. 168 | # Allowable values are: Never (a member always appears; the default); 169 | # IfBlankName (a member doesn't appear if its name is null, empty or all whitespace); 170 | # and IfParentsName (a member appears unless its name matches the parent's. 171 | # Default value: 'Never' 172 | :hide_member_if, 173 | # The estimated number of members in this level. Setting this property improves the performance of 174 | # MDSCHEMA_LEVELS, MDSCHEMA_HIERARCHIES and MDSCHEMA_DIMENSIONS XMLA requests 175 | :approx_row_count 176 | data_dictionary_names :table, :column, :name_column, :ordinal_column, :parent_column # values in XML will be uppercased when using Oracle driver 177 | elements :key_expression, :name_expression, :ordinal_expression, :member_formatter, :property 178 | end 179 | 180 | class KeyExpression < SchemaElement 181 | elements :sql 182 | end 183 | 184 | class NameExpression < SchemaElement 185 | elements :sql 186 | end 187 | 188 | class OrdinalExpression < SchemaElement 189 | elements :sql 190 | end 191 | 192 | class Sql < SchemaElement 193 | def self.name 194 | 'SQL' 195 | end 196 | attributes :dialect 197 | content :text 198 | end 199 | 200 | class Property < SchemaElement 201 | attributes :name, :description, 202 | :column, 203 | # Data type of this property: String, Numeric, Integer, Boolean, Date, Time or Timestamp. 204 | :type, 205 | # Should be set to true if the value of the property is functionally dependent on the level value. 206 | # This permits the associated property column to be omitted from the GROUP BY clause 207 | # (if the database permits columns in the SELECT that are not in the GROUP BY). 208 | # This can be a significant performance enhancement on some databases, such as MySQL. 209 | :depends_on_level_value 210 | data_dictionary_names :column 211 | elements :property_formatter 212 | end 213 | 214 | class Measure < SchemaElement 215 | attributes :name, :description, 216 | # Column which is source of this measure's values. 217 | # If not specified, a measure expression must be specified. 218 | :column, 219 | # The datatype of this measure: String, Numeric, Integer, Boolean, Date, Time or Timestamp. 220 | # The default datatype of a measure is 'Integer' if the measure's aggregator is 'Count', otherwise it is 'Numeric'. 221 | :datatype, 222 | # Aggregation function. Allowed values are "sum", "count", "min", "max", "avg", and "distinct-count". 223 | :aggregator, 224 | # Format string with which to format cells of this measure. For more details, see the mondrian.util.Format class. 225 | :format_string, 226 | # Whether this member is visible in the user-interface. Default true. 227 | :visible 228 | data_dictionary_names :column # values in XML will be uppercased when using Oracle driver 229 | elements :measure_expression, :cell_formatter 230 | end 231 | 232 | class MeasureExpression < SchemaElement 233 | elements :sql 234 | end 235 | 236 | class CalculatedMember < SchemaElement 237 | attributes :name, :description, 238 | # Name of the dimension which this member belongs to. 239 | :dimension, 240 | # Format string with which to format cells of this measure. For more details, see the mondrian.util.Format class. 241 | :format_string, 242 | # Whether this member is visible in the user-interface. Default true. 243 | :visible 244 | elements :formula, :calculated_member_property, :cell_formatter 245 | end 246 | 247 | class Formula < SchemaElement 248 | content :text 249 | end 250 | 251 | class CalculatedMemberProperty < SchemaElement 252 | attributes :name, :description, 253 | # MDX expression which defines the value of this property. If the expression is a constant string, you could enclose it in quotes, 254 | # or just specify the 'value' attribute instead. 255 | :expression, 256 | # Value of this property. If the value is not constant, specify the 'expression' attribute instead. 257 | :value 258 | end 259 | 260 | class AggName < SchemaElement 261 | attributes :name 262 | data_dictionary_names :name 263 | elements :agg_fact_count, :agg_measure, :agg_level, :agg_foreign_key 264 | end 265 | 266 | class AggFactCount < SchemaElement 267 | attributes :column 268 | data_dictionary_names :column 269 | end 270 | 271 | class AggMeasure < SchemaElement 272 | attributes :name, :column 273 | data_dictionary_names :column 274 | end 275 | 276 | class AggLevel < SchemaElement 277 | attributes :name, :column 278 | data_dictionary_names :column 279 | end 280 | 281 | class AggForeignKey < SchemaElement 282 | attributes :fact_column, :agg_column 283 | data_dictionary_names :fact_column, :agg_column 284 | end 285 | 286 | class AggIgnoreColumn < SchemaElement 287 | attributes :column 288 | data_dictionary_names :column 289 | end 290 | 291 | class AggPattern < SchemaElement 292 | attributes :pattern 293 | data_dictionary_names :pattern 294 | elements :agg_fact_count, :agg_measure, :agg_level, :agg_foreign_key, :agg_exclude 295 | end 296 | 297 | class AggExclude < SchemaElement 298 | attributes :name, :pattern, :ignorecase 299 | data_dictionary_names :name, :pattern 300 | end 301 | 302 | class Role < SchemaElement 303 | attributes :name 304 | elements :schema_grant, :union 305 | end 306 | 307 | class SchemaGrant < SchemaElement 308 | # access may be "all", "all_dimensions", "custom" or "none". 309 | # If access is "all_dimensions", the role has access to all dimensions but still needs explicit access to cubes. 310 | # If access is "custom", no access will be inherited by cubes for which no explicit rule is set. 311 | # If access is "all_dimensions", an implicut access is given to all dimensions of the schema's cubes, 312 | # provided the cube's access attribute is either "custom" or "all" 313 | attributes :access 314 | elements :cube_grant 315 | end 316 | 317 | class CubeGrant < SchemaElement 318 | # access may be "all", "custom", or "none". 319 | # If access is "custom", no access will be inherited by the dimensions of this cube, 320 | # unless the parent SchemaGrant is set to "all_dimensions" 321 | attributes :access, 322 | # The unique name of the cube 323 | :cube 324 | elements :dimension_grant, :hierarchy_grant 325 | end 326 | 327 | class DimensionGrant < SchemaElement 328 | # access may be "all", "custom" or "none". 329 | # Note that a role is implicitly given access to a dimension when it is given "all" acess to a cube. 330 | # If access is "custom", no access will be inherited by the hierarchies of this dimension. 331 | # If the parent schema access is "all_dimensions", this timension will inherit access "all". 332 | # See also the "all_dimensions" option of the "SchemaGrant" element. 333 | attributes :access, 334 | # The unique name of the dimension 335 | :dimension 336 | end 337 | 338 | class HierarchyGrant < SchemaElement 339 | # access may be "all", "custom" or "none". 340 | # If access is "custom", you may also specify the attributes :top_level, :bottom_level, and the member grants. 341 | # If access is "custom", the child levels of this hierarchy will not inherit access rights from this hierarchy, 342 | # should there be no explicit rules defined for the said child level. 343 | attributes :access, 344 | # The unique name of the hierarchy 345 | :hierarchy, 346 | # Unique name of the highest level of the hierarchy from which this role is allowed to see members. 347 | # May only be specified if the HierarchyGrant.access is "custom". 348 | # If not specified, role can see members up to the top level. 349 | :top_level, 350 | # Unique name of the lowest level of the hierarchy from which this role is allowed to see members. 351 | # May only be specified if the HierarchyGrant.access is "custom". 352 | # If not specified, role can see members down to the leaf level. 353 | :bottom_level, 354 | # Policy which determines how cell values are calculated if not all of the children of the current cell 355 | # are visible to the current role. 356 | # Allowable values are "full" (the default), "partial", and "hidden". 357 | :rollup_policy 358 | elements :member_grant 359 | end 360 | 361 | class MemberGrant < SchemaElement 362 | # The children of this member inherit that access. 363 | # You can implicitly see a member if you can see any of its children. 364 | attributes :access, 365 | # The unique name of the member 366 | :member 367 | end 368 | 369 | class Union < SchemaElement 370 | elements :role_usage 371 | end 372 | 373 | class RoleUsage < SchemaElement 374 | attributes :role_name 375 | end 376 | 377 | end 378 | end 379 | end 380 | -------------------------------------------------------------------------------- /spec/query_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Query" do 4 | def quote_table_name(name) 5 | ActiveRecord::Base.connection.quote_table_name(name) 6 | end 7 | 8 | before(:all) do 9 | @olap = Mondrian::OLAP::Connection.create(CONNECTION_PARAMS_WITH_CATALOG) 10 | @sql = ActiveRecord::Base.connection 11 | 12 | @query_string = <<-SQL 13 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 14 | {[Product].children} ON ROWS 15 | FROM [Sales] 16 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 17 | SQL 18 | 19 | @sql_select = <<-SQL 20 | SELECT SUM(unit_sales) unit_sales_sum, SUM(store_sales) store_sales_sum 21 | FROM sales 22 | LEFT JOIN products ON sales.product_id = products.id 23 | LEFT JOIN product_classes ON products.product_class_id = product_classes.id 24 | LEFT JOIN #{quote_table_name('time')} ON sales.time_id = #{quote_table_name('time')}.id 25 | LEFT JOIN customers ON sales.customer_id = customers.id 26 | WHERE #{quote_table_name('time')}.the_year = 2010 AND #{quote_table_name('time')}.quarter = 'Q1' 27 | AND customers.country = 'USA' AND customers.state_province = 'CA' 28 | GROUP BY product_classes.product_family 29 | ORDER BY product_classes.product_family 30 | SQL 31 | 32 | end 33 | 34 | def sql_select_numbers(select_string) 35 | @sql.select_rows(select_string).map do |rows| 36 | rows.map{|col| BigDecimal(col.to_s)} 37 | end 38 | end 39 | 40 | describe "result" do 41 | before(:all) do 42 | 43 | # TODO: replace hardcoded expected values with result of SQL query 44 | @expected_column_names = ["Unit Sales", "Store Sales"] 45 | @expected_column_full_names = ["[Measures].[Unit Sales]", "[Measures].[Store Sales]"] 46 | @expected_drillable_columns = [false, false] 47 | @expected_row_names = ["Drink", "Food", "Non-Consumable"] 48 | @expected_row_full_names = ["[Product].[Drink]", "[Product].[Food]", "[Product].[Non-Consumable]"] 49 | @expected_drillable_rows = [true, true, true] 50 | 51 | # AR JDBC driver always returns strings, need to convert to BigDecimal 52 | @expected_result_values = sql_select_numbers(@sql_select) 53 | 54 | @expected_result_values_by_columns = 55 | [@expected_result_values.map{|row| row[0]}, @expected_result_values.map{|row| row[1]}] 56 | 57 | @result = @olap.execute @query_string 58 | end 59 | 60 | it "should return axes" do 61 | @result.axes_count.should == 2 62 | end 63 | 64 | it "should return column names" do 65 | @result.column_names.should == @expected_column_names 66 | @result.column_full_names.should == @expected_column_full_names 67 | end 68 | 69 | it "should return row names" do 70 | @result.row_names.should == @expected_row_names 71 | @result.row_full_names.should == @expected_row_full_names 72 | end 73 | 74 | it "should return axis by index names" do 75 | @result.axis_names[0].should == @expected_column_names 76 | @result.axis_full_names[0].should == @expected_column_full_names 77 | end 78 | 79 | it "should return column members" do 80 | @result.column_members.map(&:name).should == @expected_column_names 81 | @result.column_members.map(&:full_name).should == @expected_column_full_names 82 | @result.column_members.map(&:"drillable?").should == @expected_drillable_columns 83 | end 84 | 85 | it "should return row members" do 86 | @result.row_members.map(&:name).should == @expected_row_names 87 | @result.row_members.map(&:full_name).should == @expected_row_full_names 88 | @result.row_members.map(&:"drillable?").should == @expected_drillable_rows 89 | end 90 | 91 | it "should return cells" do 92 | @result.values.should == @expected_result_values 93 | end 94 | 95 | it "should return cells with specified axes number sequence" do 96 | @result.values(0, 1).should == @expected_result_values_by_columns 97 | end 98 | 99 | it "should return cells with specified axes name sequence" do 100 | @result.values(:columns, :rows).should == @expected_result_values_by_columns 101 | end 102 | 103 | it "should return formatted cells" do 104 | @result.formatted_values.map{|r| r.map{|s| BigDecimal.new(s.gsub(',',''))}}.should == @expected_result_values 105 | end 106 | 107 | end 108 | 109 | describe "builder" do 110 | 111 | before(:each) do 112 | @query = @olap.from('Sales') 113 | end 114 | 115 | describe "from cube" do 116 | it "should return query" do 117 | @query.should be_a(Mondrian::OLAP::Query) 118 | @query.cube_name.should == 'Sales' 119 | end 120 | end 121 | 122 | describe "columns" do 123 | it "should accept list" do 124 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]').should equal(@query) 125 | @query.columns.should == ['[Measures].[Unit Sales]', '[Measures].[Store Sales]'] 126 | end 127 | 128 | it "should accept list as array" do 129 | @query.columns(['[Measures].[Unit Sales]', '[Measures].[Store Sales]']) 130 | @query.columns.should == ['[Measures].[Unit Sales]', '[Measures].[Store Sales]'] 131 | end 132 | 133 | it "should accept with several method calls" do 134 | @query.columns('[Measures].[Unit Sales]').columns('[Measures].[Store Sales]') 135 | @query.columns.should == ['[Measures].[Unit Sales]', '[Measures].[Store Sales]'] 136 | end 137 | end 138 | 139 | describe "other axis" do 140 | it "should accept axis with index member list" do 141 | @query.axis(0, '[Measures].[Unit Sales]', '[Measures].[Store Sales]') 142 | @query.axis(0).should == ['[Measures].[Unit Sales]', '[Measures].[Store Sales]'] 143 | end 144 | 145 | it "should accept rows list" do 146 | @query.rows('[Product].children') 147 | @query.rows.should == ['[Product].children'] 148 | end 149 | 150 | it "should accept pages list" do 151 | @query.pages('[Product].children') 152 | @query.pages.should == ['[Product].children'] 153 | end 154 | end 155 | 156 | describe "crossjoin" do 157 | it "should do crossjoin of several dimensions" do 158 | @query.rows('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]') 159 | @query.rows.should == [:crossjoin, ['[Product].children'], ['[Customers].[Canada]', '[Customers].[USA]']] 160 | end 161 | 162 | it "should do crossjoin passing array as first argument" do 163 | @query.rows('[Product].children').crossjoin(['[Customers].[Canada]', '[Customers].[USA]']) 164 | @query.rows.should == [:crossjoin, ['[Product].children'], ['[Customers].[Canada]', '[Customers].[USA]']] 165 | end 166 | end 167 | 168 | describe "nonempty_crossjoin" do 169 | it "should do nonempty_crossjoin of several dimensions" do 170 | @query.rows('[Product].children').nonempty_crossjoin('[Customers].[Canada]', '[Customers].[USA]') 171 | @query.rows.should == [:nonempty_crossjoin, ['[Product].children'], ['[Customers].[Canada]', '[Customers].[USA]']] 172 | end 173 | end 174 | 175 | describe "nonempty" do 176 | it "should limit to set of members with nonempty values" do 177 | @query.rows('[Product].children').nonempty 178 | @query.rows.should == [:nonempty, ['[Product].children']] 179 | end 180 | end 181 | 182 | describe "order" do 183 | it "should order by one measure" do 184 | @query.rows('[Product].children').order('[Measures].[Unit Sales]', :bdesc) 185 | @query.rows.should == [:order, ['[Product].children'], '[Measures].[Unit Sales]', 'BDESC'] 186 | end 187 | 188 | it "should order using String order direction" do 189 | @query.rows('[Product].children').order('[Measures].[Unit Sales]', 'DESC') 190 | @query.rows.should == [:order, ['[Product].children'], '[Measures].[Unit Sales]', 'DESC'] 191 | end 192 | 193 | it "should order by measure and other member" do 194 | @query.rows('[Product].children').order(['[Measures].[Unit Sales]', '[Customers].[USA]'], :basc) 195 | @query.rows.should == [:order, ['[Product].children'], ['[Measures].[Unit Sales]', '[Customers].[USA]'], 'BASC'] 196 | end 197 | end 198 | 199 | %w(top bottom).each do |extreme| 200 | describe extreme do 201 | it "should select #{extreme} count rows by measure" do 202 | @query.rows('[Product].children').send(:"#{extreme}_count", 5, '[Measures].[Unit Sales]') 203 | @query.rows.should == [:"#{extreme}_count", ['[Product].children'], 5, '[Measures].[Unit Sales]'] 204 | end 205 | 206 | it "should select #{extreme} count rows without measure" do 207 | @query.rows('[Product].children').send(:"#{extreme}_count", 5) 208 | @query.rows.should == [:"#{extreme}_count", ['[Product].children'], 5] 209 | end 210 | 211 | it "should select #{extreme} percent rows by measure" do 212 | @query.rows('[Product].children').send(:"#{extreme}_percent", 20, '[Measures].[Unit Sales]') 213 | @query.rows.should == [:"#{extreme}_percent", ['[Product].children'], 20, '[Measures].[Unit Sales]'] 214 | end 215 | 216 | it "should select #{extreme} sum rows by measure" do 217 | @query.rows('[Product].children').send(:"#{extreme}_sum", 1000, '[Measures].[Unit Sales]') 218 | @query.rows.should == [:"#{extreme}_sum", ['[Product].children'], 1000, '[Measures].[Unit Sales]'] 219 | end 220 | end 221 | end 222 | 223 | describe "hierarchize" do 224 | it "should hierarchize simple set" do 225 | @query.rows('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize 226 | @query.rows.should == [:hierarchize, ['[Customers].[Country].Members', '[Customers].[City].Members']] 227 | end 228 | 229 | it "should hierarchize last set of crossjoin" do 230 | @query.rows('[Product].children').crossjoin('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize 231 | @query.rows.should == [:crossjoin, ['[Product].children'], 232 | [:hierarchize, ['[Customers].[Country].Members', '[Customers].[City].Members']]] 233 | end 234 | 235 | it "should hierarchize last set of nonempty_crossjoin" do 236 | @query.rows('[Product].children').nonempty_crossjoin('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize 237 | @query.rows.should == [:nonempty_crossjoin, ['[Product].children'], 238 | [:hierarchize, ['[Customers].[Country].Members', '[Customers].[City].Members']]] 239 | end 240 | 241 | it "should hierarchize all crossjoin" do 242 | @query.rows('[Product].children').crossjoin('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize_all 243 | @query.rows.should == [:hierarchize, [:crossjoin, ['[Product].children'], 244 | ['[Customers].[Country].Members', '[Customers].[City].Members']]] 245 | end 246 | 247 | it "should hierarchize with POST" do 248 | @query.rows('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize(:post) 249 | @query.rows.should == [:hierarchize, ['[Customers].[Country].Members', '[Customers].[City].Members'], 'POST'] 250 | end 251 | 252 | end 253 | 254 | describe "except" do 255 | it "should except one set from other" do 256 | @query.rows('[Customers].[Country].Members').except('[Customers].[USA]') 257 | @query.rows.should == [:except, ['[Customers].[Country].Members'], ['[Customers].[USA]']] 258 | end 259 | 260 | it "should except from last set of crossjoin" do 261 | @query.rows('[Product].children').crossjoin('[Customers].[Country].Members').except('[Customers].[USA]') 262 | @query.rows.should == [:crossjoin, ['[Product].children'], 263 | [:except, ['[Customers].[Country].Members'], ['[Customers].[USA]']]] 264 | end 265 | 266 | it "should except from last set of nonempty_crossjoin" do 267 | @query.rows('[Product].children').nonempty_crossjoin('[Customers].[Country].Members').except('[Customers].[USA]') 268 | @query.rows.should == [:nonempty_crossjoin, ['[Product].children'], 269 | [:except, ['[Customers].[Country].Members'], ['[Customers].[USA]']]] 270 | end 271 | end 272 | 273 | describe "filter" do 274 | it "should filter set by condition" do 275 | @query.rows('[Customers].[Country].Members').filter('[Measures].[Unit Sales] > 1000') 276 | @query.rows.should == [:filter, ['[Customers].[Country].Members'], '[Measures].[Unit Sales] > 1000'] 277 | end 278 | 279 | it "should filter using set alias" do 280 | @query.rows('[Customers].[Country].Members').filter('NOT ISEMPTY(S.CURRENT)', :as => 'S') 281 | @query.rows.should == [:filter, ['[Customers].[Country].Members'], 'NOT ISEMPTY(S.CURRENT)', 'S'] 282 | end 283 | end 284 | 285 | describe "where" do 286 | it "should accept conditions" do 287 | @query.where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]').should equal(@query) 288 | @query.where.should == ['[Time].[2010].[Q1]', '[Customers].[USA].[CA]'] 289 | end 290 | 291 | it "should accept conditions as array" do 292 | @query.where(['[Time].[2010].[Q1]', '[Customers].[USA].[CA]']) 293 | @query.where.should == ['[Time].[2010].[Q1]', '[Customers].[USA].[CA]'] 294 | end 295 | 296 | it "should accept conditions with several method calls" do 297 | @query.where('[Time].[2010].[Q1]').where('[Customers].[USA].[CA]') 298 | @query.where.should == ['[Time].[2010].[Q1]', '[Customers].[USA].[CA]'] 299 | end 300 | 301 | it "should do crossjoin of where conditions" do 302 | @query.where('[Customers].[USA]').crossjoin('[Time].[2011].[Q1]', '[Time].[2011].[Q2]') 303 | @query.where.should == [:crossjoin, ['[Customers].[USA]'], ['[Time].[2011].[Q1]', '[Time].[2011].[Q2]']] 304 | end 305 | 306 | it "should do nonempty_crossjoin of where conditions" do 307 | @query.where('[Customers].[USA]').nonempty_crossjoin('[Time].[2011].[Q1]', '[Time].[2011].[Q2]') 308 | @query.where.should == [:nonempty_crossjoin, ['[Customers].[USA]'], ['[Time].[2011].[Q1]', '[Time].[2011].[Q2]']] 309 | end 310 | end 311 | 312 | describe "with member" do 313 | it "should accept definition" do 314 | @query.with_member('[Measures].[ProfitPct]'). 315 | as('Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])'). 316 | should equal(@query) 317 | @query.with.should == [ 318 | [ :member, '[Measures].[ProfitPct]', 319 | 'Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])' 320 | ] 321 | ] 322 | end 323 | 324 | it "should accept definition with additional parameters" do 325 | @query.with_member('[Measures].[ProfitPct]'). 326 | as('Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 327 | :solve_order => 1, 328 | :format_string => 'Percent') 329 | @query.with.should == [ 330 | [ :member, '[Measures].[ProfitPct]', 331 | 'Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 332 | {:solve_order => 1, :format_string => 'Percent'} 333 | ] 334 | ] 335 | end 336 | end 337 | 338 | describe "with set" do 339 | it "should accept simple defition" do 340 | @query.with_set('SelectedRows').as('[Product].children') 341 | @query.with.should == [ 342 | [ :set, 'SelectedRows', 343 | ['[Product].children'] 344 | ] 345 | ] 346 | end 347 | 348 | it "should accept definition with crossjoin" do 349 | @query.with_set('SelectedRows').as('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]') 350 | @query.with.should == [ 351 | [ :set, 'SelectedRows', 352 | [:crossjoin, ['[Product].children'], ['[Customers].[Canada]', '[Customers].[USA]']] 353 | ] 354 | ] 355 | end 356 | 357 | it "should accept definition with nonempty_crossjoin" do 358 | @query.with_set('SelectedRows').as('[Product].children').nonempty_crossjoin('[Customers].[Canada]', '[Customers].[USA]') 359 | @query.with.should == [ 360 | [ :set, 'SelectedRows', 361 | [:nonempty_crossjoin, ['[Product].children'], ['[Customers].[Canada]', '[Customers].[USA]']] 362 | ] 363 | ] 364 | end 365 | end 366 | 367 | describe "to MDX" do 368 | it "should return MDX query" do 369 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 370 | rows('[Product].children'). 371 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 372 | to_mdx.should be_like <<-SQL 373 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 374 | [Product].children ON ROWS 375 | FROM [Sales] 376 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 377 | SQL 378 | end 379 | 380 | it "should return query with crossjoin" do 381 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 382 | rows('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 383 | where('[Time].[2010].[Q1]'). 384 | to_mdx.should be_like <<-SQL 385 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 386 | CROSSJOIN([Product].children, {[Customers].[Canada], [Customers].[USA]}) ON ROWS 387 | FROM [Sales] 388 | WHERE ([Time].[2010].[Q1]) 389 | SQL 390 | end 391 | 392 | it "should return query with several crossjoins" do 393 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 394 | rows('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 395 | crossjoin('[Time].[2010].[Q1]', '[Time].[2010].[Q2]'). 396 | to_mdx.should be_like <<-SQL 397 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 398 | CROSSJOIN(CROSSJOIN([Product].children, {[Customers].[Canada], [Customers].[USA]}), 399 | {[Time].[2010].[Q1], [Time].[2010].[Q2]}) ON ROWS 400 | FROM [Sales] 401 | SQL 402 | end 403 | 404 | it "should return query with crossjoin and nonempty" do 405 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 406 | rows('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]').nonempty. 407 | where('[Time].[2010].[Q1]'). 408 | to_mdx.should be_like <<-SQL 409 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 410 | NON EMPTY CROSSJOIN([Product].children, {[Customers].[Canada], [Customers].[USA]}) ON ROWS 411 | FROM [Sales] 412 | WHERE ([Time].[2010].[Q1]) 413 | SQL 414 | end 415 | 416 | it "should return query with nonempty_crossjoin" do 417 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 418 | rows('[Product].children').nonempty_crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 419 | where('[Time].[2010].[Q1]'). 420 | to_mdx.should be_like <<-SQL 421 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 422 | NONEMPTYCROSSJOIN([Product].children, {[Customers].[Canada], [Customers].[USA]}) ON ROWS 423 | FROM [Sales] 424 | WHERE ([Time].[2010].[Q1]) 425 | SQL 426 | end 427 | 428 | it "should return query with where with several same dimension members" do 429 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 430 | rows('[Product].children'). 431 | where('[Customers].[Canada]', '[Customers].[USA]'). 432 | to_mdx.should be_like <<-SQL 433 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 434 | [Product].children ON ROWS 435 | FROM [Sales] 436 | WHERE {[Customers].[Canada], [Customers].[USA]} 437 | SQL 438 | end 439 | 440 | it "should return query with where with several different dimension members returned by function" do 441 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 442 | rows('[Product].children'). 443 | where('Head([Customers].Members).Item(0)', 'Head([Gender].Members).Item(0)'). 444 | to_mdx.should be_like <<-SQL 445 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 446 | [Product].children ON ROWS 447 | FROM [Sales] 448 | WHERE (Head([Customers].Members).Item(0), Head([Gender].Members).Item(0)) 449 | SQL 450 | end 451 | 452 | it "should return query with where with crossjoin" do 453 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 454 | rows('[Product].children'). 455 | where('[Customers].[USA]').crossjoin('[Time].[2011].[Q1]', '[Time].[2011].[Q2]'). 456 | to_mdx.should be_like <<-SQL 457 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 458 | [Product].children ON ROWS 459 | FROM [Sales] 460 | WHERE CROSSJOIN({[Customers].[USA]}, {[Time].[2011].[Q1], [Time].[2011].[Q2]}) 461 | SQL 462 | end 463 | 464 | it "should return query with where with nonempty_crossjoin" do 465 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 466 | rows('[Product].children'). 467 | where('[Customers].[USA]').nonempty_crossjoin('[Time].[2011].[Q1]', '[Time].[2011].[Q2]'). 468 | to_mdx.should be_like <<-SQL 469 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 470 | [Product].children ON ROWS 471 | FROM [Sales] 472 | WHERE NONEMPTYCROSSJOIN({[Customers].[USA]}, {[Time].[2011].[Q1], [Time].[2011].[Q2]}) 473 | SQL 474 | end 475 | 476 | it "should return query with order by one measure" do 477 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 478 | rows('[Product].children').order('[Measures].[Unit Sales]', :bdesc). 479 | to_mdx.should be_like <<-SQL 480 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 481 | ORDER([Product].children, [Measures].[Unit Sales], BDESC) ON ROWS 482 | FROM [Sales] 483 | SQL 484 | end 485 | 486 | it "should return query with order by measure and other member" do 487 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 488 | rows('[Product].children').order(['[Measures].[Unit Sales]', '[Customers].[USA]'], :asc). 489 | to_mdx.should be_like <<-SQL 490 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 491 | ORDER([Product].children, ([Measures].[Unit Sales], [Customers].[USA]), ASC) ON ROWS 492 | FROM [Sales] 493 | SQL 494 | end 495 | 496 | %w(top bottom).each do |extreme| 497 | it "should return query with #{extreme} count by one measure" do 498 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 499 | rows('[Product].children').send(:"#{extreme}_count", 5, '[Measures].[Unit Sales]'). 500 | to_mdx.should be_like <<-SQL 501 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 502 | #{extreme.upcase}COUNT([Product].children, 5, [Measures].[Unit Sales]) ON ROWS 503 | FROM [Sales] 504 | SQL 505 | end 506 | 507 | it "should return query with #{extreme} count without measure" do 508 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 509 | rows('[Product].children').send(:"#{extreme}_count", 5). 510 | to_mdx.should be_like <<-SQL 511 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 512 | #{extreme.upcase}COUNT([Product].children, 5) ON ROWS 513 | FROM [Sales] 514 | SQL 515 | end 516 | 517 | it "should return query with #{extreme} count by measure and other member" do 518 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 519 | rows('[Product].children').send(:"#{extreme}_count", 5, ['[Measures].[Unit Sales]', '[Customers].[USA]']). 520 | to_mdx.should be_like <<-SQL 521 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 522 | #{extreme.upcase}COUNT([Product].children, 5, ([Measures].[Unit Sales], [Customers].[USA])) ON ROWS 523 | FROM [Sales] 524 | SQL 525 | end 526 | 527 | it "should return query with #{extreme} percent by one measure" do 528 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 529 | rows('[Product].children').send(:"#{extreme}_percent", 20, '[Measures].[Unit Sales]'). 530 | to_mdx.should be_like <<-SQL 531 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 532 | #{extreme.upcase}PERCENT([Product].children, 20, [Measures].[Unit Sales]) ON ROWS 533 | FROM [Sales] 534 | SQL 535 | end 536 | 537 | it "should return query with #{extreme} sum by one measure" do 538 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 539 | rows('[Product].children').send(:"#{extreme}_sum", 1000, '[Measures].[Unit Sales]'). 540 | to_mdx.should be_like <<-SQL 541 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 542 | #{extreme.upcase}SUM([Product].children, 1000, [Measures].[Unit Sales]) ON ROWS 543 | FROM [Sales] 544 | SQL 545 | end 546 | end 547 | 548 | it "should return query with hierarchize" do 549 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 550 | rows('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize. 551 | to_mdx.should be_like <<-SQL 552 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 553 | HIERARCHIZE({[Customers].[Country].Members, [Customers].[City].Members}) ON ROWS 554 | FROM [Sales] 555 | SQL 556 | end 557 | 558 | it "should return query with hierarchize and order" do 559 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 560 | rows('[Customers].[Country].Members', '[Customers].[City].Members').hierarchize(:post). 561 | to_mdx.should be_like <<-SQL 562 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 563 | HIERARCHIZE({[Customers].[Country].Members, [Customers].[City].Members}, POST) ON ROWS 564 | FROM [Sales] 565 | SQL 566 | end 567 | 568 | it "should return query with except" do 569 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 570 | rows('[Customers].[Country].Members').except('[Customers].[USA]'). 571 | to_mdx.should be_like <<-SQL 572 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 573 | EXCEPT([Customers].[Country].Members, {[Customers].[USA]}) ON ROWS 574 | FROM [Sales] 575 | SQL 576 | end 577 | 578 | it "should return query with filter" do 579 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 580 | rows('[Customers].[Country].Members').filter('[Measures].[Unit Sales] > 1000'). 581 | to_mdx.should be_like <<-SQL 582 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 583 | FILTER([Customers].[Country].Members, [Measures].[Unit Sales] > 1000) ON ROWS 584 | FROM [Sales] 585 | SQL 586 | end 587 | 588 | it "should return query with filter and set alias" do 589 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 590 | rows('[Customers].[Country].Members').filter('NOT ISEMPTY(S.CURRENT)', :as => 'S'). 591 | to_mdx.should be_like <<-SQL 592 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 593 | FILTER([Customers].[Country].Members AS S, NOT ISEMPTY(S.CURRENT)) ON ROWS 594 | FROM [Sales] 595 | SQL 596 | end 597 | 598 | it "should return query with filter non-empty" do 599 | @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 600 | rows('[Customers].[Country].Members').filter_nonempty. 601 | to_mdx.should be_like <<-SQL 602 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 603 | FILTER([Customers].[Country].Members AS S, NOT ISEMPTY(S.CURRENT)) ON ROWS 604 | FROM [Sales] 605 | SQL 606 | end 607 | 608 | it "should return query including WITH MEMBER clause" do 609 | @query. 610 | with_member('[Measures].[ProfitPct]'). 611 | as('Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 612 | :solve_order => 1, :format_string => 'Percent'). 613 | with_member('[Measures].[ProfitValue]'). 614 | as('[Measures].[Store Sales] * [Measures].[ProfitPct]', 615 | :solve_order => 2, :format_string => 'Currency'). 616 | columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 617 | rows('[Product].children'). 618 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 619 | to_mdx.should be_like <<-SQL 620 | WITH 621 | MEMBER [Measures].[ProfitPct] AS 622 | 'Val((Measures.[Store Sales] - Measures.[Store Cost]) / Measures.[Store Sales])', 623 | SOLVE_ORDER = 1, FORMAT_STRING = 'Percent' 624 | MEMBER [Measures].[ProfitValue] AS 625 | '[Measures].[Store Sales] * [Measures].[ProfitPct]', 626 | SOLVE_ORDER = 2, FORMAT_STRING = 'Currency' 627 | SELECT {[Measures].[Unit Sales], [Measures].[Store Sales]} ON COLUMNS, 628 | [Product].children ON ROWS 629 | FROM [Sales] 630 | WHERE ([Time].[2010].[Q1], [Customers].[USA].[CA]) 631 | SQL 632 | end 633 | 634 | it "should return query including WITH SET clause" do 635 | @query.with_set('SelectedRows'). 636 | as('[Product].children').crossjoin('[Customers].[Canada]', '[Customers].[USA]'). 637 | with_member('[Measures].[Profit]'). 638 | as('[Measures].[Store Sales] - [Measures].[Store Cost]'). 639 | columns('[Measures].[Profit]'). 640 | rows('SelectedRows'). 641 | to_mdx.should be_like <<-SQL 642 | WITH 643 | SET SelectedRows AS 644 | 'CROSSJOIN([Product].children, {[Customers].[Canada], [Customers].[USA]})' 645 | MEMBER [Measures].[Profit] AS 646 | '[Measures].[Store Sales] - [Measures].[Store Cost]' 647 | SELECT {[Measures].[Profit]} ON COLUMNS, 648 | SelectedRows ON ROWS 649 | FROM [Sales] 650 | SQL 651 | end 652 | end 653 | 654 | describe "execute" do 655 | it "should return result" do 656 | result = @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 657 | rows('[Product].children'). 658 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 659 | execute 660 | result.values.should == sql_select_numbers(@sql_select) 661 | end 662 | end 663 | 664 | describe "result HTML formatting" do 665 | it "should format result" do 666 | result = @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 667 | rows('[Product].children'). 668 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 669 | execute 670 | Nokogiri::HTML.fragment(result.to_html).css('tr').size.should == (sql_select_numbers(@sql_select).size + 1) 671 | end 672 | 673 | # it "test" do 674 | # puts @olap.from('Sales'). 675 | # columns('[Product].children'). 676 | # rows('[Customers].[USA].[CA].children'). 677 | # where('[Time].[2010].[Q1]', '[Measures].[Store Sales]'). 678 | # execute.to_html 679 | # end 680 | end 681 | 682 | end 683 | 684 | describe "errors" do 685 | before(:each) do 686 | @query = @olap.from('Sales') 687 | end 688 | 689 | it "should raise error when invalid MDX statement" do 690 | expect { 691 | @olap.execute "SELECT dummy FROM" 692 | }.to raise_error {|e| 693 | e.should be_kind_of(Mondrian::OLAP::Error) 694 | e.message.should == 'org.olap4j.OlapException: mondrian gave exception while parsing query' 695 | e.root_cause_message.should == "Syntax error at line 1, column 14, token 'FROM'" 696 | } 697 | end 698 | 699 | it "should raise error when invalid MDX object" do 700 | expect { 701 | @query.columns('[Measures].[Dummy]').execute 702 | }.to raise_error {|e| 703 | e.should be_kind_of(Mondrian::OLAP::Error) 704 | e.message.should == 'org.olap4j.OlapException: mondrian gave exception while parsing query' 705 | e.root_cause_message.should == "MDX object '[Measures].[Dummy]' not found in cube 'Sales'" 706 | } 707 | end 708 | 709 | it "should raise error when invalid formula" do 710 | expect { 711 | @query.with_member('[Measures].[Dummy]').as('Dummy(123)'). 712 | columns('[Measures].[Dummy]').execute 713 | }.to raise_error {|e| 714 | e.should be_kind_of(Mondrian::OLAP::Error) 715 | e.message.should == 'org.olap4j.OlapException: mondrian gave exception while parsing query' 716 | e.root_cause_message.should == "No function matches signature 'Dummy()'" 717 | } 718 | end 719 | 720 | end 721 | 722 | describe "drill through" do 723 | before(:all) do 724 | @query = @olap.from('Sales') 725 | @result = @query.columns('[Measures].[Unit Sales]', '[Measures].[Store Sales]'). 726 | rows('[Product].children'). 727 | where('[Time].[2010].[Q1]', '[Customers].[USA].[CA]'). 728 | execute 729 | @drill_through = @result.drill_through(:row => 0, :column => 0) 730 | end 731 | 732 | it "should return column types" do 733 | @drill_through.column_types.should == [ 734 | :INT, :VARCHAR, :INT, :INT, :INT, 735 | :VARCHAR, :VARCHAR, :VARCHAR, :VARCHAR, :VARCHAR, :VARCHAR, 736 | :VARCHAR, :VARCHAR, :VARCHAR, :INT, 737 | :VARCHAR, 738 | :DECIMAL 739 | ] 740 | end if MONDRIAN_DRIVER == 'mysql' 741 | 742 | it "should return column names" do 743 | # ignore calculated customer full name column name which is shown differently on each database 744 | @drill_through.column_names[0..12].should == %w( 745 | the_year quarter month_of_year week_of_year day_of_month 746 | product_family product_department product_category product_subcategory brand_name product_name 747 | state_province city 748 | ) 749 | @drill_through.column_names[14..16].should == %w( 750 | id gender unit_sales 751 | ) 752 | end if %w(mysql postgresql).include? MONDRIAN_DRIVER 753 | 754 | it "should return table names" do 755 | @drill_through.table_names.should == [ 756 | "time", "time", "time", "time", "time", 757 | "product_classes", "product_classes", "product_classes", "product_classes", "products", "products", 758 | "customers", "customers", "", "customers", 759 | "customers", 760 | "sales" 761 | ] 762 | end if %w(mysql postgresql).include? MONDRIAN_DRIVER 763 | 764 | it "should return column labels" do 765 | @drill_through.column_labels.should == [ 766 | "Year", "Quarter", "Month", "Week", "Day", 767 | "Product Family", "Product Department", "Product Category", "Product Subcategory", "Brand Name", "Product Name", 768 | "State Province", "City", "Name", "Name (Key)", 769 | "Gender", 770 | "Unit Sales" 771 | ] 772 | end 773 | 774 | it "should return row values" do 775 | @drill_through.rows.size.should == 15 # number of generated test rows 776 | end 777 | 778 | it "should return correct row value types" do 779 | @drill_through.rows.first.map(&:class).should == 780 | case MONDRIAN_DRIVER 781 | when "oracle" 782 | [ 783 | BigDecimal, String, BigDecimal, BigDecimal, BigDecimal, 784 | String, String, String, String, String, String, 785 | String, String, String, BigDecimal, 786 | String, 787 | BigDecimal 788 | ] 789 | else 790 | [ 791 | Fixnum, String, Fixnum, Fixnum, Fixnum, 792 | String, String, String, String, String, String, 793 | String, String, String, Fixnum, 794 | String, 795 | BigDecimal 796 | ] 797 | end 798 | end 799 | 800 | it "should return only specified max rows" do 801 | drill_through = @result.drill_through(:row => 0, :column => 0, :max_rows => 10) 802 | drill_through.rows.size.should == 10 803 | end 804 | end 805 | 806 | 807 | end 808 | --------------------------------------------------------------------------------