mondrian-rest 0.5.0-java
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +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 [](https://travis-ci.org/jazzido/mondrian-rest) [](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: []
|