mondrian-rest 0.5.0-java
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/.travis.yml +9 -0
- data/Gemfile +5 -0
- data/README.md +38 -0
- data/Rakefile +21 -0
- data/lib/mondrian_rest/api.rb +175 -0
- data/lib/mondrian_rest/api_formatters.rb +126 -0
- data/lib/mondrian_rest/api_helpers.rb +50 -0
- data/lib/mondrian_rest/mondrian_ext.rb +201 -0
- data/lib/mondrian_rest/query_helper.rb +188 -0
- data/lib/mondrian_rest/version.rb +3 -0
- data/lib/mondrian_rest.rb +9 -0
- data/mondrian-rest.gemspec +27 -0
- metadata +219 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: fca6cfddd8cf42e7d87ec8e35f10b8389fdd08ff
|
4
|
+
data.tar.gz: 08b4017a7cdc2eebc4f0b2218b51822396061dd5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9fba6d67d771da283e085b0d89784baa093b5676b0c737aa89cdc5ef2b4ba1e9f3c9a68574db21b85759733e7f1161ec538e2051820bc641d446a62ac6577fd9
|
7
|
+
data.tar.gz: 44ed30d9adf06ddfbb7fe157a8a2a6d5e1765936b82fbdbc2b24bbc12d2dbf88f23c41285d3c1f745034e82d4228d8b936128de6583c1e7e51b5d17d8fcacc59
|
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
.DS_Store
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# mondrian-rest [![Build Status](https://travis-ci.org/jazzido/mondrian-rest.svg?branch=public)](https://travis-ci.org/jazzido/mondrian-rest) [![Coverage Status](https://coveralls.io/repos/github/jazzido/mondrian-rest/badge.svg?branch=public)](https://coveralls.io/github/jazzido/mondrian-rest?branch=public)
|
2
|
+
|
3
|
+
A REST endpoint for [Mondrian](http://community.pentaho.com/projects/mondrian/)
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
See [`mondrian-rest-demo`](https://github.com/jazzido/mondrian-rest-demo) and [`mondrian-rest-foodmart`](https://github.com/jazzido/mondrian-rest-foodmart) for example OLAP cubes implemented with `mondrian-rest`.
|
8
|
+
|
9
|
+
[`mondrian-rest-ui`](https://github.com/jazzido/mondrian-rest-ui) is an experimental visualization tool for `mondrian-rest`, inspired by [CubesViewer](https://github.com/jjmontesl/cubesviewer) and [Polestar](https://github.com/vega/polestar).
|
10
|
+
|
11
|
+
## Credits
|
12
|
+
|
13
|
+
Initial inspiration for this project came from the [Stefan Urbanek](https://github.com/Stiivi)'s [`cubes`](http://cubes.databrewery.org/) OLAP server. `mondrian-rest` uses [Raimonds Simanovskis](https://github.com/rsim)'s fantastic [`mondrian-olap`](https://github.com/rsim/mondrian-olap) library.
|
14
|
+
|
15
|
+
|
16
|
+
## License
|
17
|
+
|
18
|
+
The MIT License (MIT)
|
19
|
+
|
20
|
+
Copyright (c) 2015 Manuel Aristarán
|
21
|
+
|
22
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
23
|
+
of this software and associated documentation files (the "Software"), to deal
|
24
|
+
in the Software without restriction, including without limitation the rights
|
25
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
26
|
+
copies of the Software, and to permit persons to whom the Software is
|
27
|
+
furnished to do so, subject to the following conditions:
|
28
|
+
|
29
|
+
The above copyright notice and this permission notice shall be included in all
|
30
|
+
copies or substantial portions of the Software.
|
31
|
+
|
32
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
33
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
34
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
35
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
36
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
37
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
38
|
+
SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
require "rspec/core/rake_task"
|
2
|
+
|
3
|
+
require_relative './lib/mondrian_rest.rb'
|
4
|
+
|
5
|
+
desc "Run specs"
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
RSpec::Core::RakeTask.new(:rcov) do |t|
|
8
|
+
t.rcov = true
|
9
|
+
t.rcov_opts = ['--exclude', '/Library,spec/']
|
10
|
+
end
|
11
|
+
|
12
|
+
desc "API Routes"
|
13
|
+
task :routes do
|
14
|
+
Mondrian::REST::Api.routes.each do |api|
|
15
|
+
method = api.route_method.ljust(10)
|
16
|
+
path = api.route_path
|
17
|
+
puts " #{method} #{path}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
task :default => [:spec]
|
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
require_relative './api_helpers.rb'
|
4
|
+
require_relative './query_helper.rb'
|
5
|
+
require_relative './api_formatters.rb'
|
6
|
+
|
7
|
+
module Mondrian::REST
|
8
|
+
|
9
|
+
class Api < Grape::API
|
10
|
+
version '1', using: :header, vendor: 'mondrian_rest'
|
11
|
+
format :json
|
12
|
+
|
13
|
+
helpers Mondrian::REST::APIHelpers
|
14
|
+
helpers Mondrian::REST::QueryHelper
|
15
|
+
helpers do
|
16
|
+
def logger
|
17
|
+
Api.logger
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
resource :mdx do
|
22
|
+
content_type :xls, "application/vnd.ms-excel"
|
23
|
+
formatter :xls, Mondrian::REST::Formatters::XLS
|
24
|
+
content_type :csv, "text/csv"
|
25
|
+
formatter :csv, Mondrian::REST::Formatters::CSV
|
26
|
+
content_type :json, "application/json"
|
27
|
+
formatter :json, Mondrian::REST::Formatters::AggregationJSON
|
28
|
+
|
29
|
+
desc "Execute an MDX query against a cube"
|
30
|
+
content_type :txt, "text/plain"
|
31
|
+
post do
|
32
|
+
status 200
|
33
|
+
|
34
|
+
rbody = env['api.request.body'].force_encoding('utf-8')
|
35
|
+
mdx(rbody)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
resource :cubes do
|
40
|
+
desc "Returns the cubes defined in this server's schema"
|
41
|
+
get do
|
42
|
+
{
|
43
|
+
'cubes' => olap.cube_names.map { |cn| olap.cube(cn).to_h }
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
route_param :cube_name do
|
48
|
+
desc "Return a cube"
|
49
|
+
params do
|
50
|
+
requires :cube_name, type: String, desc: "Cube name"
|
51
|
+
end
|
52
|
+
|
53
|
+
get do
|
54
|
+
cube = get_cube_or_404(params[:cube_name])
|
55
|
+
cube.to_h
|
56
|
+
end
|
57
|
+
|
58
|
+
resource :members do
|
59
|
+
desc "return a member by its full name"
|
60
|
+
params do
|
61
|
+
requires :full_name,
|
62
|
+
type: String,
|
63
|
+
regexp: /[a-z0-9\.,\-\s%\\[\\]\(\)]+/i
|
64
|
+
end
|
65
|
+
get do
|
66
|
+
member_full_name = URI.decode(params[:full_name])
|
67
|
+
|
68
|
+
m = get_member(get_cube_or_404(params[:cube_name]),
|
69
|
+
member_full_name)
|
70
|
+
if m.nil?
|
71
|
+
error!("Member `#{member_full_name}` not found in cube `#{params[:cube_name]}`", 404)
|
72
|
+
end
|
73
|
+
m.to_h.merge({
|
74
|
+
ancestors: m.ancestors.map(&:to_h),
|
75
|
+
dimension: m.dimension_info
|
76
|
+
})
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
resource :aggregate do
|
82
|
+
content_type :xls, "application/vnd.ms-excel"
|
83
|
+
formatter :xls, Mondrian::REST::Formatters::XLS
|
84
|
+
content_type :csv, "text/csv"
|
85
|
+
formatter :csv, Mondrian::REST::Formatters::CSV
|
86
|
+
content_type :json, "application/json"
|
87
|
+
formatter :json, Mondrian::REST::Formatters::AggregationJSON
|
88
|
+
|
89
|
+
desc "aggregate from query parameters"
|
90
|
+
params do
|
91
|
+
optional :measures, type: Array
|
92
|
+
optional :cut, type: Array, desc: "Specification of slicer axis"
|
93
|
+
optional :drilldown, type: Array, desc: "Dimension(s) to be drilled down"
|
94
|
+
optional :nonempty, type: Boolean, desc: "Only return non empty cells"
|
95
|
+
optional :distinct, type: Boolean, desc: "Apply DISTINCT() to every axis"
|
96
|
+
optional :parents, type: Boolean, desc: "Include members' ancestors"
|
97
|
+
optional :debug, type: Boolean, desc: "Include generated MDX", default: false
|
98
|
+
optional :properties, type: Array, desc: "Include member properties"
|
99
|
+
end
|
100
|
+
get do
|
101
|
+
cube = get_cube_or_404(params[:cube_name])
|
102
|
+
query = build_query(cube, params)
|
103
|
+
mdx_query = query.to_mdx
|
104
|
+
|
105
|
+
result = mdx(query.to_mdx)
|
106
|
+
result.mdx = mdx_query if params[:debug]
|
107
|
+
result.properties = params[:properties]
|
108
|
+
result.cube = cube
|
109
|
+
|
110
|
+
result
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
resource :dimensions do
|
115
|
+
route_param :dimension_name do
|
116
|
+
desc "Return a dimension's members"
|
117
|
+
params do
|
118
|
+
requires :cube_name, type: String, desc: "Cube name"
|
119
|
+
requires :dimension_name, type: String, desc: "Dimension name"
|
120
|
+
end
|
121
|
+
|
122
|
+
get do
|
123
|
+
cube = get_cube_or_404(params[:cube_name])
|
124
|
+
dimension = cube.dimension(params[:dimension_name])
|
125
|
+
dimension.to_h
|
126
|
+
end
|
127
|
+
|
128
|
+
resource :levels do
|
129
|
+
route_param :level_name do
|
130
|
+
resource :members do
|
131
|
+
|
132
|
+
params do
|
133
|
+
optional :member_properties, type: Array
|
134
|
+
end
|
135
|
+
|
136
|
+
get do
|
137
|
+
cube = get_cube_or_404(params[:cube_name])
|
138
|
+
|
139
|
+
dimension = cube.dimension(params[:dimension_name])
|
140
|
+
if dimension.nil?
|
141
|
+
error!("dimension #{params[:dimension_name]} not found in cube #{params[:cube_name]}", 404)
|
142
|
+
end
|
143
|
+
|
144
|
+
level = dimension.hierarchies[0].level(params[:level_name])
|
145
|
+
if level.nil?
|
146
|
+
error!("level #{params[:level_name]} not found in dimension #{params[:dimension_name]}")
|
147
|
+
end
|
148
|
+
|
149
|
+
level.to_h(params[:member_properties])
|
150
|
+
end
|
151
|
+
|
152
|
+
route_param :member_key,
|
153
|
+
type: String,
|
154
|
+
requirements: { member_key: /[a-z0-9\.\-\s]+/i } do
|
155
|
+
get do
|
156
|
+
cube = get_cube_or_404(params[:cube_name])
|
157
|
+
dimension = cube.dimension(params[:dimension_name])
|
158
|
+
level = dimension.hierarchies[0].level(params[:level_name])
|
159
|
+
|
160
|
+
member = level.members.detect { |m|
|
161
|
+
m.property_value('MEMBER_KEY').to_s == params[:member_key]
|
162
|
+
}
|
163
|
+
error!('member not found', 404) if member.nil?
|
164
|
+
member.to_h.merge({ancestors: member.ancestors.map(&:to_h)})
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'writeexcel'
|
3
|
+
|
4
|
+
module Mondrian::REST::Formatters
|
5
|
+
|
6
|
+
module AggregationJSON
|
7
|
+
def self.call(result, env)
|
8
|
+
add_parents = env['rack.request.query_hash']['parents'] == 'true'
|
9
|
+
debug = env['rack.request.query_hash']['debug'] == 'true'
|
10
|
+
|
11
|
+
result.to_h(add_parents, debug).to_json
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module XLS
|
16
|
+
def self.call(result, env)
|
17
|
+
add_parents = env['rack.request.query_hash']['parents'] == 'true'
|
18
|
+
debug = env['rack.request.query_hash']['debug'] == 'true'
|
19
|
+
|
20
|
+
out = StringIO.new
|
21
|
+
book = WriteExcel.new(out)
|
22
|
+
sheet = book.add_worksheet
|
23
|
+
|
24
|
+
Mondrian::REST::Formatters
|
25
|
+
.tidy(result,
|
26
|
+
add_parents: add_parents,
|
27
|
+
debug: debug)
|
28
|
+
.each_with_index do |row, i|
|
29
|
+
row.each_with_index { |cell, j|
|
30
|
+
sheet.write(i, j, cell)
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
book.close
|
35
|
+
out.string
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
module CSV
|
40
|
+
def self.call(result, env)
|
41
|
+
add_parents = env['rack.request.query_hash']['parents'] == 'true'
|
42
|
+
debug = env['rack.request.query_hash']['debug'] == 'true'
|
43
|
+
|
44
|
+
rows = Mondrian::REST::Formatters.tidy(result,
|
45
|
+
add_parents: add_parents,
|
46
|
+
debug: debug)
|
47
|
+
|
48
|
+
::CSV.generate do |csv|
|
49
|
+
rows.each { |row| csv << row }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
##
|
55
|
+
# Generate 'tidy data' (http://vita.had.co.nz/papers/tidy-data.pdf)
|
56
|
+
# from a result set.
|
57
|
+
def self.tidy(result, options)
|
58
|
+
cube = result.cube
|
59
|
+
|
60
|
+
add_parents = options[:add_parents]
|
61
|
+
rs = result.to_h(add_parents, options[:debug])
|
62
|
+
measures = rs[:axes].first[:members]
|
63
|
+
dimensions = rs[:axis_dimensions][1..-1]
|
64
|
+
columns = []
|
65
|
+
slices = []
|
66
|
+
level_has_all = []
|
67
|
+
|
68
|
+
Enumerator.new do |y|
|
69
|
+
dimensions.each do |dd|
|
70
|
+
if add_parents
|
71
|
+
hier = cube.dimension(dd[:name])
|
72
|
+
.hierarchies
|
73
|
+
.first
|
74
|
+
|
75
|
+
level_has_all << hier.has_all?
|
76
|
+
slices << dd[:level_depth]
|
77
|
+
|
78
|
+
hier
|
79
|
+
.levels[(hier.has_all? ? 1 : 0)...dd[:level_depth]]
|
80
|
+
.each do |ancestor_level|
|
81
|
+
|
82
|
+
columns += ["ID #{ancestor_level.caption}", ancestor_level.caption]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
columns += ["ID #{dd[:level]}", dd[:level]]
|
87
|
+
end
|
88
|
+
|
89
|
+
# append measure columns and yield table header
|
90
|
+
y.yield columns + pluck(measures, :name)
|
91
|
+
|
92
|
+
prod = rs[:axes][1..-1].map { |e|
|
93
|
+
e[:members].map.with_index { |e_, i| [e_,i] }
|
94
|
+
}
|
95
|
+
values = rs[:values]
|
96
|
+
|
97
|
+
prod.shift.product(*prod).each do |cell|
|
98
|
+
cidxs = cell.map { |c,i| i }.reverse
|
99
|
+
|
100
|
+
cm = cell.map(&:first)
|
101
|
+
|
102
|
+
msrs = measures.map.with_index { |m, mi|
|
103
|
+
(cidxs + [mi]).reduce(values) { |_, idx| _[idx] }
|
104
|
+
}
|
105
|
+
if add_parents
|
106
|
+
vdim = cm.each.with_index.reduce([]) { |cnames, (member, j)|
|
107
|
+
member[:ancestors][0...slices[j] - (level_has_all[j] ? 1 : 0)].each { |ancestor|
|
108
|
+
cnames += [ancestor[:key], ancestor[:caption]]
|
109
|
+
}
|
110
|
+
cnames += [member[:key], member[:caption]]
|
111
|
+
}
|
112
|
+
y.yield vdim + msrs
|
113
|
+
else
|
114
|
+
y.yield pluck(cm, :key)
|
115
|
+
.zip(pluck(cm, :caption))
|
116
|
+
.flatten \
|
117
|
+
+ msrs
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.pluck(a, m)
|
124
|
+
a.map { |e| e[m] }
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Mondrian::REST
|
2
|
+
module APIHelpers
|
3
|
+
|
4
|
+
@@olap = nil
|
5
|
+
@@mdx_parser = nil
|
6
|
+
|
7
|
+
def olap
|
8
|
+
if @@olap.nil?
|
9
|
+
@@olap = Mondrian::OLAP::Connection.new(env['mondrian-olap.params'])
|
10
|
+
@@olap.connect
|
11
|
+
end
|
12
|
+
@@olap
|
13
|
+
end
|
14
|
+
|
15
|
+
##
|
16
|
+
# Returns an instance of org.olap4j.mdx.parser.MdxParser
|
17
|
+
def mdx_parser
|
18
|
+
if @@mdx_parser.nil?
|
19
|
+
@@mdx_parser = olap.raw_connection.getParserFactory
|
20
|
+
.createMdxParser(olap.raw_connection)
|
21
|
+
end
|
22
|
+
@@mdx_parser
|
23
|
+
end
|
24
|
+
|
25
|
+
def olap_flush
|
26
|
+
if olap.connected?
|
27
|
+
olap.flush_schema_cache
|
28
|
+
olap.close
|
29
|
+
end
|
30
|
+
olap.connect
|
31
|
+
end
|
32
|
+
|
33
|
+
def get_cube_or_404(name)
|
34
|
+
cube = olap.cube(name)
|
35
|
+
error!('Not found', 404) if cube.nil?
|
36
|
+
cube
|
37
|
+
end
|
38
|
+
|
39
|
+
def mdx(query)
|
40
|
+
logger.info("Executing MDX query #{query}")
|
41
|
+
begin
|
42
|
+
result = olap.execute query
|
43
|
+
return result
|
44
|
+
rescue Mondrian::OLAP::Error => st
|
45
|
+
error!({error: st.backtrace}, 400)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
module Mondrian
|
2
|
+
module OLAP
|
3
|
+
|
4
|
+
class Cube
|
5
|
+
def to_h
|
6
|
+
# gather dimensions
|
7
|
+
dimensions = self.dimensions
|
8
|
+
.find_all { |d| d.dimension_type != :measures }
|
9
|
+
.map { |d|
|
10
|
+
{
|
11
|
+
:name => d.name,
|
12
|
+
:caption => d.caption,
|
13
|
+
:type => d.dimension_type,
|
14
|
+
:annotations => d.annotations,
|
15
|
+
:hierarchies => d.hierarchies.map { |h|
|
16
|
+
{
|
17
|
+
:name => h.name,
|
18
|
+
:has_all => h.has_all?,
|
19
|
+
:all_member_name => h.all_member_name,
|
20
|
+
:levels => h.levels.map { |l|
|
21
|
+
{
|
22
|
+
:name => l.name,
|
23
|
+
:full_name => l.full_name,
|
24
|
+
:caption => l.caption
|
25
|
+
}
|
26
|
+
}
|
27
|
+
}
|
28
|
+
}
|
29
|
+
}
|
30
|
+
}
|
31
|
+
|
32
|
+
return {
|
33
|
+
:name => self.name,
|
34
|
+
:annotations => self.annotations,
|
35
|
+
:dimensions => dimensions,
|
36
|
+
:measures => self.dimensions
|
37
|
+
.find(&:measures?)
|
38
|
+
.hierarchy
|
39
|
+
.levels.first
|
40
|
+
.members
|
41
|
+
.find_all(&:visible?)
|
42
|
+
.map do |m|
|
43
|
+
{
|
44
|
+
:name => m.name,
|
45
|
+
:caption => m.caption,
|
46
|
+
:annotations => m.annotations,
|
47
|
+
:full_name => m.full_name
|
48
|
+
}
|
49
|
+
end
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
class Dimension
|
56
|
+
def to_h
|
57
|
+
{
|
58
|
+
hierarchies: self.hierarchies.map { |h|
|
59
|
+
{
|
60
|
+
name: h.name,
|
61
|
+
has_all: h.has_all?,
|
62
|
+
levels: h.levels.map(&:to_h) #/levels
|
63
|
+
} # /hierarchies
|
64
|
+
} #/map
|
65
|
+
} #/ dimension
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class Level
|
70
|
+
attr_reader :hierarchy
|
71
|
+
|
72
|
+
def full_name
|
73
|
+
@full_name ||= @raw_level.getUniqueName
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_h(member_properties=[])
|
77
|
+
{
|
78
|
+
name: self.name,
|
79
|
+
caption: self.caption,
|
80
|
+
members: self.members
|
81
|
+
.uniq { |m| m.property_value('MEMBER_KEY') }
|
82
|
+
.map { |m| m.to_h(member_properties) }
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class Member
|
88
|
+
|
89
|
+
def raw_level
|
90
|
+
@raw_member.getLevel
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_h(properties=[])
|
94
|
+
kv = [:name, :full_name, :caption, :all_member?,
|
95
|
+
:drillable?, :depth].map { |m|
|
96
|
+
[m, self.send(m)]
|
97
|
+
}
|
98
|
+
kv << [:key, self.property_value('MEMBER_KEY')]
|
99
|
+
kv << [:num_children, self.property_value('CHILDREN_CARDINALITY')]
|
100
|
+
kv << [:parent_name, self.property_value('PARENT_UNIQUE_NAME')]
|
101
|
+
|
102
|
+
if properties.size > 0
|
103
|
+
kv << [
|
104
|
+
:properties,
|
105
|
+
properties.reduce({}) { |h, p| h[p] = self.property_value(p); h }
|
106
|
+
]
|
107
|
+
end
|
108
|
+
|
109
|
+
Hash[kv]
|
110
|
+
end
|
111
|
+
|
112
|
+
def dimension_info
|
113
|
+
d = @raw_member.getDimension
|
114
|
+
l = @raw_member.getLevel
|
115
|
+
{
|
116
|
+
name: d.getName,
|
117
|
+
caption: d.getCaption,
|
118
|
+
type: self.dimension_type,
|
119
|
+
level: l.getCaption,
|
120
|
+
level_depth: l.depth
|
121
|
+
}
|
122
|
+
end
|
123
|
+
|
124
|
+
def ancestors
|
125
|
+
@raw_member.getAncestorMembers.map { |am|
|
126
|
+
self.class.new(am)
|
127
|
+
}
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
class Result
|
132
|
+
|
133
|
+
attr_accessor :cube, :mdx, :properties
|
134
|
+
|
135
|
+
def to_json
|
136
|
+
to_h.to_json
|
137
|
+
end
|
138
|
+
|
139
|
+
def to_h(parents=false, debug=false)
|
140
|
+
# XXX TODO
|
141
|
+
# return the contents of the filter axis
|
142
|
+
# puts self.raw_cell_set.getFilterAxis.inspect
|
143
|
+
|
144
|
+
dimensions = self.axis_members.map { |am| am.first.dimension_info }
|
145
|
+
|
146
|
+
pprops = unless self.properties.nil?
|
147
|
+
parse_properties(dimensions[1..-1]) # exclude Measures dimension
|
148
|
+
else
|
149
|
+
{}
|
150
|
+
end
|
151
|
+
|
152
|
+
rv = {
|
153
|
+
axes: self.axis_members.each_with_index.map { |a, i|
|
154
|
+
{
|
155
|
+
members: a.map { |m|
|
156
|
+
mh = m.to_h(pprops[m.raw_member.getDimension.name] || [])
|
157
|
+
if parents
|
158
|
+
mh.merge!({
|
159
|
+
ancestors: m.ancestors.map { |ma|
|
160
|
+
ma.to_h(pprops[ma.raw_member.getDimension.name] || [])
|
161
|
+
}
|
162
|
+
})
|
163
|
+
end
|
164
|
+
mh
|
165
|
+
}
|
166
|
+
}
|
167
|
+
},
|
168
|
+
axis_dimensions: dimensions,
|
169
|
+
values: self.values
|
170
|
+
}
|
171
|
+
|
172
|
+
rv[:mdx] = self.mdx if debug
|
173
|
+
|
174
|
+
rv
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
##
|
181
|
+
#
|
182
|
+
def parse_properties(dimensions)
|
183
|
+
self.properties.reduce({}) { |h, p|
|
184
|
+
sl = org.olap4j.mdx.IdentifierNode.parseIdentifier(p).getSegmentList.to_a
|
185
|
+
if sl.size != 2
|
186
|
+
raise "Properties must be in the form `Dimension.Property Name`"
|
187
|
+
end
|
188
|
+
|
189
|
+
# check that the dimension is in the drilldown list
|
190
|
+
if dimensions.find { |ad| sl[0].name == ad[:name] }.nil?
|
191
|
+
raise "Dimension `#{sl[0].name}` not in drilldown list"
|
192
|
+
end
|
193
|
+
|
194
|
+
h[sl[0].name] ||= []
|
195
|
+
h[sl[0].name] << sl[1].name
|
196
|
+
h
|
197
|
+
}
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
module Mondrian::REST
|
2
|
+
module QueryHelper
|
3
|
+
|
4
|
+
def unparse_node(node)
|
5
|
+
sw = java.io.StringWriter.new
|
6
|
+
ptw = org.olap4j.mdx.ParseTreeWriter.new(sw)
|
7
|
+
node.unparse(ptw)
|
8
|
+
sw.toString
|
9
|
+
end
|
10
|
+
|
11
|
+
def get_dimension(cube, dname)
|
12
|
+
cube_dimensions = cube.dimensions
|
13
|
+
.find_all { |d| d.dimension_type != :measures }
|
14
|
+
dim = cube_dimensions.find { |d| d.name == dname }
|
15
|
+
error!("Dimension #{dname} does not exist", 400) if dim.nil?
|
16
|
+
dim
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_member(cube, member_exp)
|
20
|
+
begin
|
21
|
+
rm = cube.raw_cube
|
22
|
+
.lookupMember(org.olap4j.mdx.IdentifierNode.parseIdentifier(member_exp).getSegmentList)
|
23
|
+
rescue Java::JavaLang::IllegalArgumentException
|
24
|
+
error!("Illegal member expression: #{member_exp}", 400)
|
25
|
+
end
|
26
|
+
member = nil
|
27
|
+
unless rm.nil?
|
28
|
+
member = Mondrian::OLAP::Member.new(rm)
|
29
|
+
end
|
30
|
+
member
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_cut(cube, cut_expr)
|
34
|
+
p = mdx_parser.parseExpression(cut_expr)
|
35
|
+
|
36
|
+
case p
|
37
|
+
when org.olap4j.mdx.CallNode
|
38
|
+
case p.getOperatorName
|
39
|
+
when "{}"
|
40
|
+
# check that the set contains only Members of a single dimension level
|
41
|
+
ls = p.getArgList.map { |id_node|
|
42
|
+
get_member(cube, unparse_node(id_node)).raw_level
|
43
|
+
}.uniq
|
44
|
+
unless ls.size == 1
|
45
|
+
error!("Illegal cut: " + cut_expr, 400)
|
46
|
+
end
|
47
|
+
{ level: ls.first, cut: unparse_node(p), type: :set }
|
48
|
+
when "()"
|
49
|
+
# check that the range contains a valid range
|
50
|
+
|
51
|
+
unless p.getArgList.first.is_a?(org.olap4j.mdx.CallNode) \
|
52
|
+
and p.getArgList.first.getOperatorName == ':'
|
53
|
+
error!("Illegal cut: " + cut_expr, 400)
|
54
|
+
end
|
55
|
+
|
56
|
+
ls = p.getArgList.first.getArgList.map { |id_node|
|
57
|
+
get_member(cube, unparse_node(id_node)).raw_level
|
58
|
+
}.uniq
|
59
|
+
|
60
|
+
unless ls.size == 1
|
61
|
+
error!("Illegal cut: " + cut_expr, 400)
|
62
|
+
end
|
63
|
+
|
64
|
+
{ level: ls.first, cut: unparse_node(p), type: :range }
|
65
|
+
else
|
66
|
+
error!("Illegal cut: " + cut_expr, 400)
|
67
|
+
end
|
68
|
+
when org.olap4j.mdx.IdentifierNode
|
69
|
+
# if `cut_expr` looks like a member, check that it's level is
|
70
|
+
# equal to `level`
|
71
|
+
m = get_member(cube, cut_expr)
|
72
|
+
{ level: m.raw_level, cut: cut_expr, type: :member }
|
73
|
+
else
|
74
|
+
error!("Illegal cut: " + cut_expr, 400)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# Parses a drilldown specification
|
80
|
+
# XXX TODO write doc
|
81
|
+
def parse_drilldown(cube, drilldown)
|
82
|
+
begin
|
83
|
+
s = org.olap4j.mdx.IdentifierNode.parseIdentifier(drilldown).getSegmentList
|
84
|
+
rescue Java::JavaLang::IllegalArgumentException
|
85
|
+
error!("Illegal drilldown specification: #{drilldown}", 400)
|
86
|
+
end
|
87
|
+
|
88
|
+
if s.size > 3 || s.map(&:quoting).any? { |q| q.name == 'KEY' }
|
89
|
+
error!("Illegal drilldown specification: #{drilldown}", 400)
|
90
|
+
return
|
91
|
+
end
|
92
|
+
|
93
|
+
dimension = get_dimension(cube, s.first.name)
|
94
|
+
hierarchy = dimension.hierarchies.first
|
95
|
+
level = hierarchy.levels[hierarchy.has_all? ? 1 : 0]
|
96
|
+
|
97
|
+
if s.size > 1
|
98
|
+
if s.size == 3 # 3 parts, means that a hierarchy was provided
|
99
|
+
hierarchy = dimension.hierarchies.find { |h_| h_.name == "#{dimension.name}.#{s[1].name}" }
|
100
|
+
if hierarchy.nil?
|
101
|
+
error!("Hierarchy `#{s[1].name}` does not exist in dimension #{dimension.name}", 404)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
level = hierarchy.levels.find { |l_| l_.name == s[s.size - 1].name }
|
105
|
+
if level.nil?
|
106
|
+
error!("Level `#{s[1].name}` does not exist in #{dimension.name}", 404)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
level
|
111
|
+
end
|
112
|
+
|
113
|
+
def build_query(cube, options={})
|
114
|
+
|
115
|
+
measure_members = cube.dimension('Measures').hierarchy.levels.first.members
|
116
|
+
options = {
|
117
|
+
'cut' => [],
|
118
|
+
'drilldown' => [],
|
119
|
+
'measures' => [measure_members.first.name],
|
120
|
+
'nonempty' => false,
|
121
|
+
'distinct' => false
|
122
|
+
}.merge(options)
|
123
|
+
|
124
|
+
# validate measures exist
|
125
|
+
cm_names = measure_members.map(&:name)
|
126
|
+
|
127
|
+
options['measures'].each { |m|
|
128
|
+
error!("Measure #{m} does not exist in cube #{cube.name}", 404) unless cm_names.include?(m)
|
129
|
+
}
|
130
|
+
|
131
|
+
# measures go in axis(0) of the resultset
|
132
|
+
query = olap.from(cube.name)
|
133
|
+
.axis(0,
|
134
|
+
*options['measures'].map { |m|
|
135
|
+
measure_members.find { |cm| cm.name == m }.full_name
|
136
|
+
})
|
137
|
+
if options['nonempty']
|
138
|
+
query = query.nonempty
|
139
|
+
end
|
140
|
+
axis_idx = 1
|
141
|
+
|
142
|
+
query_axes = options['drilldown'].map { |dd| parse_drilldown(cube, dd) }
|
143
|
+
|
144
|
+
slicer_axis = options['cut'].reduce({}) { |h, cut_expr|
|
145
|
+
pc = parse_cut(cube, cut_expr)
|
146
|
+
h[pc[:level]] = pc
|
147
|
+
h
|
148
|
+
}
|
149
|
+
|
150
|
+
dd = query_axes.map do |qa|
|
151
|
+
# there's a slice (cut) on this axis
|
152
|
+
if slicer_axis[qa.raw_level]
|
153
|
+
cut = slicer_axis.delete(qa.raw_level)
|
154
|
+
case cut[:type]
|
155
|
+
when :member
|
156
|
+
"{#{cut[:cut]}}"
|
157
|
+
else
|
158
|
+
cut[:cut]
|
159
|
+
end
|
160
|
+
else
|
161
|
+
qa.raw_level.unique_name + '.Members'
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# query axes (drilldown)
|
166
|
+
dd.each do |ds|
|
167
|
+
query = query.axis(axis_idx,
|
168
|
+
ds)
|
169
|
+
|
170
|
+
if options['distinct']
|
171
|
+
query = query.distinct
|
172
|
+
end
|
173
|
+
|
174
|
+
if options['nonempty']
|
175
|
+
query = query.nonempty
|
176
|
+
end
|
177
|
+
|
178
|
+
axis_idx += 1
|
179
|
+
end
|
180
|
+
|
181
|
+
# slicer axes (cut)
|
182
|
+
if slicer_axis.size >= 1
|
183
|
+
query = query.where(slicer_axis.values.map { |v| v[:cut] })
|
184
|
+
end
|
185
|
+
query
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
Gem::Specification.new do |s|
|
3
|
+
s.name = "mondrian-rest"
|
4
|
+
s.version = '0.5.0'
|
5
|
+
s.authors = ["Manuel Aristarán"]
|
6
|
+
s.email = ["manuel@jazzido.com"]
|
7
|
+
s.homepage = "https://github.com/jazzido/mondrian-rest"
|
8
|
+
s.summary = %q{A REST interface for Mondrian ROLAP server}
|
9
|
+
s.description = %q{A REST interface for Mondrian ROLAP server}
|
10
|
+
s.license = 'MIT'
|
11
|
+
|
12
|
+
s.platform = 'java'
|
13
|
+
|
14
|
+
s.files = `git ls-files`.split("\n").reject { |f| f =~ /^spec\// }
|
15
|
+
s.require_paths = ["lib"]
|
16
|
+
|
17
|
+
s.add_runtime_dependency 'mondrian-olap', ["~> 0.7.0"]
|
18
|
+
s.add_runtime_dependency 'grape', ["~> 0.14.0"]
|
19
|
+
s.add_runtime_dependency 'writeexcel', '~> 1.0', '>= 1.0.5'
|
20
|
+
|
21
|
+
s.add_development_dependency 'rake', '~> 10.4', '>= 10.4.2'
|
22
|
+
s.add_development_dependency 'rspec', '~> 3.4', '>= 3.4.0'
|
23
|
+
s.add_development_dependency 'jdbc-derby', '~> 10.11', '>= 10.11.1.1'
|
24
|
+
s.add_development_dependency 'jdbc-sqlite3', '~> 3.8', '>= 3.8.11.2'
|
25
|
+
s.add_development_dependency 'rack-test', '~> 0.6.3'
|
26
|
+
s.add_development_dependency 'rubyzip', '~> 1.1', '>= 1.1.7'
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,219 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mondrian-rest
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.0
|
5
|
+
platform: java
|
6
|
+
authors:
|
7
|
+
- Manuel Aristarán
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-10-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 0.7.0
|
19
|
+
name: mondrian-olap
|
20
|
+
prerelease: false
|
21
|
+
type: :runtime
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.7.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 0.14.0
|
33
|
+
name: grape
|
34
|
+
prerelease: false
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.14.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.0'
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: 1.0.5
|
50
|
+
name: writeexcel
|
51
|
+
prerelease: false
|
52
|
+
type: :runtime
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - "~>"
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '1.0'
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: 1.0.5
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - "~>"
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '10.4'
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: 10.4.2
|
70
|
+
name: rake
|
71
|
+
prerelease: false
|
72
|
+
type: :development
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - "~>"
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '10.4'
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: 10.4.2
|
81
|
+
- !ruby/object:Gem::Dependency
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - "~>"
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '3.4'
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 3.4.0
|
90
|
+
name: rspec
|
91
|
+
prerelease: false
|
92
|
+
type: :development
|
93
|
+
version_requirements: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - "~>"
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '3.4'
|
98
|
+
- - ">="
|
99
|
+
- !ruby/object:Gem::Version
|
100
|
+
version: 3.4.0
|
101
|
+
- !ruby/object:Gem::Dependency
|
102
|
+
requirement: !ruby/object:Gem::Requirement
|
103
|
+
requirements:
|
104
|
+
- - "~>"
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: '10.11'
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 10.11.1.1
|
110
|
+
name: jdbc-derby
|
111
|
+
prerelease: false
|
112
|
+
type: :development
|
113
|
+
version_requirements: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '10.11'
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: 10.11.1.1
|
121
|
+
- !ruby/object:Gem::Dependency
|
122
|
+
requirement: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - "~>"
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '3.8'
|
127
|
+
- - ">="
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: 3.8.11.2
|
130
|
+
name: jdbc-sqlite3
|
131
|
+
prerelease: false
|
132
|
+
type: :development
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - "~>"
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: '3.8'
|
138
|
+
- - ">="
|
139
|
+
- !ruby/object:Gem::Version
|
140
|
+
version: 3.8.11.2
|
141
|
+
- !ruby/object:Gem::Dependency
|
142
|
+
requirement: !ruby/object:Gem::Requirement
|
143
|
+
requirements:
|
144
|
+
- - "~>"
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
version: 0.6.3
|
147
|
+
name: rack-test
|
148
|
+
prerelease: false
|
149
|
+
type: :development
|
150
|
+
version_requirements: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - "~>"
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: 0.6.3
|
155
|
+
- !ruby/object:Gem::Dependency
|
156
|
+
requirement: !ruby/object:Gem::Requirement
|
157
|
+
requirements:
|
158
|
+
- - "~>"
|
159
|
+
- !ruby/object:Gem::Version
|
160
|
+
version: '1.1'
|
161
|
+
- - ">="
|
162
|
+
- !ruby/object:Gem::Version
|
163
|
+
version: 1.1.7
|
164
|
+
name: rubyzip
|
165
|
+
prerelease: false
|
166
|
+
type: :development
|
167
|
+
version_requirements: !ruby/object:Gem::Requirement
|
168
|
+
requirements:
|
169
|
+
- - "~>"
|
170
|
+
- !ruby/object:Gem::Version
|
171
|
+
version: '1.1'
|
172
|
+
- - ">="
|
173
|
+
- !ruby/object:Gem::Version
|
174
|
+
version: 1.1.7
|
175
|
+
description: A REST interface for Mondrian ROLAP server
|
176
|
+
email:
|
177
|
+
- manuel@jazzido.com
|
178
|
+
executables: []
|
179
|
+
extensions: []
|
180
|
+
extra_rdoc_files: []
|
181
|
+
files:
|
182
|
+
- ".gitignore"
|
183
|
+
- ".travis.yml"
|
184
|
+
- Gemfile
|
185
|
+
- README.md
|
186
|
+
- Rakefile
|
187
|
+
- lib/mondrian_rest.rb
|
188
|
+
- lib/mondrian_rest/api.rb
|
189
|
+
- lib/mondrian_rest/api_formatters.rb
|
190
|
+
- lib/mondrian_rest/api_helpers.rb
|
191
|
+
- lib/mondrian_rest/mondrian_ext.rb
|
192
|
+
- lib/mondrian_rest/query_helper.rb
|
193
|
+
- lib/mondrian_rest/version.rb
|
194
|
+
- mondrian-rest.gemspec
|
195
|
+
homepage: https://github.com/jazzido/mondrian-rest
|
196
|
+
licenses:
|
197
|
+
- MIT
|
198
|
+
metadata: {}
|
199
|
+
post_install_message:
|
200
|
+
rdoc_options: []
|
201
|
+
require_paths:
|
202
|
+
- lib
|
203
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
204
|
+
requirements:
|
205
|
+
- - ">="
|
206
|
+
- !ruby/object:Gem::Version
|
207
|
+
version: '0'
|
208
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
209
|
+
requirements:
|
210
|
+
- - ">="
|
211
|
+
- !ruby/object:Gem::Version
|
212
|
+
version: '0'
|
213
|
+
requirements: []
|
214
|
+
rubyforge_project:
|
215
|
+
rubygems_version: 2.4.8
|
216
|
+
signing_key:
|
217
|
+
specification_version: 4
|
218
|
+
summary: A REST interface for Mondrian ROLAP server
|
219
|
+
test_files: []
|