gooddata 0.2.0
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.
- data/LICENSE +22 -0
- data/README.rdoc +97 -0
- data/VERSION +1 -0
- data/bin/gooddata +13 -0
- data/bin/igd.rb +33 -0
- data/lib/gooddata.rb +3 -0
- data/lib/gooddata/client.rb +196 -0
- data/lib/gooddata/command.rb +75 -0
- data/lib/gooddata/commands/api.rb +37 -0
- data/lib/gooddata/commands/auth.rb +74 -0
- data/lib/gooddata/commands/base.rb +80 -0
- data/lib/gooddata/commands/datasets.rb +228 -0
- data/lib/gooddata/commands/help.rb +104 -0
- data/lib/gooddata/commands/profile.rb +9 -0
- data/lib/gooddata/commands/projects.rb +51 -0
- data/lib/gooddata/commands/version.rb +7 -0
- data/lib/gooddata/connection.rb +220 -0
- data/lib/gooddata/extract.rb +13 -0
- data/lib/gooddata/helpers.rb +18 -0
- data/lib/gooddata/model.rb +558 -0
- data/lib/gooddata/models/links.rb +42 -0
- data/lib/gooddata/models/metadata.rb +82 -0
- data/lib/gooddata/models/profile.rb +32 -0
- data/lib/gooddata/models/project.rb +136 -0
- data/lib/gooddata/version.rb +3 -0
- data/test/helper.rb +10 -0
- data/test/test_commands.rb +85 -0
- data/test/test_guessing.rb +46 -0
- data/test/test_model.rb +63 -0
- data/test/test_rest_api_basic.rb +41 -0
- data/test/test_upload.rb +52 -0
- metadata +202 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
module GoodData
|
2
|
+
module Command
|
3
|
+
class Projects < Base
|
4
|
+
def list
|
5
|
+
connect
|
6
|
+
Project.all.each do |project|
|
7
|
+
puts "%s %s" % [project.uri, project.title]
|
8
|
+
end
|
9
|
+
end
|
10
|
+
alias :index :list
|
11
|
+
|
12
|
+
def create
|
13
|
+
connect
|
14
|
+
|
15
|
+
title = ask "Project name"
|
16
|
+
summary = ask "Project summary"
|
17
|
+
template = ask "Project template", :default => ''
|
18
|
+
|
19
|
+
project = Project.create :title => title, :summary => summary, :template => template
|
20
|
+
|
21
|
+
puts "Project '#{project.title}' with id #{project.uri} created successfully!"
|
22
|
+
end
|
23
|
+
|
24
|
+
def show
|
25
|
+
id = args.shift rescue nil
|
26
|
+
raise(CommandFailed, "Specify the project key you wish to show.") if id.nil?
|
27
|
+
connect
|
28
|
+
pp Project[id].to_json
|
29
|
+
end
|
30
|
+
|
31
|
+
def delete
|
32
|
+
raise(CommandFailed, "Specify the project key(s) for the project(s) you wish to delete.") if args.size == 0
|
33
|
+
connect
|
34
|
+
while args.size > 0
|
35
|
+
id = args.shift
|
36
|
+
project = Project[id]
|
37
|
+
ask "Do you want to delete the project '#{project.title}' with id #{project.uri}", :answers => %w(y n) do |answer|
|
38
|
+
case answer
|
39
|
+
when 'y' then
|
40
|
+
puts "Deleting #{project.title}..."
|
41
|
+
project.delete
|
42
|
+
puts "Project '#{project.title}' with id #{project.uri} deleted successfully!"
|
43
|
+
when 'n' then
|
44
|
+
puts "Aborting..."
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,220 @@
|
|
1
|
+
require 'json/pure'
|
2
|
+
require 'net/ftptls'
|
3
|
+
|
4
|
+
# silence the parenthesis warning in rest-client 1.6.1
|
5
|
+
old_verbose, $VERBOSE = $VERBOSE, nil ; require 'rest-client' ; $VERBOSE = old_verbose
|
6
|
+
|
7
|
+
module GoodData
|
8
|
+
|
9
|
+
# = GoodData HTTP wrapper
|
10
|
+
#
|
11
|
+
# Provides a convenient HTTP wrapper for talking with the GoodData API.
|
12
|
+
#
|
13
|
+
# Remember that the connection is shared amongst the entire application.
|
14
|
+
# Therefore you can't be logged in to more than _one_ GoodData account.
|
15
|
+
# per session. Simultaneous connections to multiple GoodData accounts is not
|
16
|
+
# supported at this time.
|
17
|
+
#
|
18
|
+
# The GoodData API is a RESTful API that communicates using JSON. This wrapper
|
19
|
+
# makes sure that the session is stored between requests and that the JSON is
|
20
|
+
# parsed both when sending and receiving.
|
21
|
+
#
|
22
|
+
# == Usage
|
23
|
+
#
|
24
|
+
# Before a connection can be made to the GoodData API, you have to supply the user
|
25
|
+
# credentials using the set_credentials method:
|
26
|
+
#
|
27
|
+
# Connection.new(username, password).set_credentials(username, password)
|
28
|
+
#
|
29
|
+
# To send a HTTP request use either the get, post or delete methods documented below.
|
30
|
+
#
|
31
|
+
class Connection
|
32
|
+
|
33
|
+
DEFAULT_URL = 'https://secure.gooddata.com'
|
34
|
+
LOGIN_PATH = '/gdc/account/login'
|
35
|
+
TOKEN_PATH = '/gdc/account/token'
|
36
|
+
|
37
|
+
# Set the GoodData account credentials.
|
38
|
+
#
|
39
|
+
# This have to be performed before any calls to the API.
|
40
|
+
#
|
41
|
+
# === Parameters
|
42
|
+
#
|
43
|
+
# * +username+ - The GoodData account username
|
44
|
+
# * +password+ - The GoodData account password
|
45
|
+
def initialize(username, password, url = nil)
|
46
|
+
@status = :not_connected
|
47
|
+
@username = username
|
48
|
+
@password = password
|
49
|
+
@url = url || DEFAULT_URL
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns the user JSON object of the currently logged in GoodData user account.
|
53
|
+
def user
|
54
|
+
ensure_connection
|
55
|
+
@user
|
56
|
+
end
|
57
|
+
|
58
|
+
# Performs a HTTP GET request.
|
59
|
+
#
|
60
|
+
# Retuns the JSON response formatted as a Hash object.
|
61
|
+
#
|
62
|
+
# === Parameters
|
63
|
+
#
|
64
|
+
# * +path+ - The HTTP path on the GoodData server (must be prefixed with a forward slash)
|
65
|
+
#
|
66
|
+
# === Examples
|
67
|
+
#
|
68
|
+
# Connection.new(username, password).get '/gdc/projects'
|
69
|
+
def get(path, options = {})
|
70
|
+
GoodData.logger.debug "GET #{path}"
|
71
|
+
ensure_connection
|
72
|
+
process_response(options) { @server[path].get cookies }
|
73
|
+
end
|
74
|
+
|
75
|
+
# Performs a HTTP POST request.
|
76
|
+
#
|
77
|
+
# Retuns the JSON response formatted as a Hash object.
|
78
|
+
#
|
79
|
+
# === Parameters
|
80
|
+
#
|
81
|
+
# * +path+ - The HTTP path on the GoodData server (must be prefixed with a forward slash)
|
82
|
+
# * +data+ - The payload data in the format of a Hash object
|
83
|
+
#
|
84
|
+
# === Examples
|
85
|
+
#
|
86
|
+
# Connection.new(username, password).post '/gdc/projects', { ... }
|
87
|
+
def post(path, data, options = {})
|
88
|
+
payload = data.to_json
|
89
|
+
GoodData.logger.debug "POST #{path}, payload: #{payload}"
|
90
|
+
ensure_connection
|
91
|
+
process_response(options) { @server[path].post payload, cookies }
|
92
|
+
end
|
93
|
+
|
94
|
+
# Performs a HTTP DELETE request.
|
95
|
+
#
|
96
|
+
# Retuns the JSON response formatted as a Hash object.
|
97
|
+
#
|
98
|
+
# === Parameters
|
99
|
+
#
|
100
|
+
# * +path+ - The HTTP path on the GoodData server (must be prefixed with a forward slash)
|
101
|
+
#
|
102
|
+
# === Examples
|
103
|
+
#
|
104
|
+
# Connection.new(username, password).delete '/gdc/project/1'
|
105
|
+
def delete(path)
|
106
|
+
GoodData.logger.debug "DELETE #{path}"
|
107
|
+
ensure_connection
|
108
|
+
process_response { @server[path].delete cookies }
|
109
|
+
end
|
110
|
+
|
111
|
+
# Get the cookies associated with the current connection.
|
112
|
+
def cookies
|
113
|
+
@cookies ||= { :cookies => {} }
|
114
|
+
end
|
115
|
+
|
116
|
+
# Set the cookies used when communicating with the GoodData API.
|
117
|
+
def merge_cookies!(cookies)
|
118
|
+
self.cookies
|
119
|
+
@cookies[:cookies].merge! cookies
|
120
|
+
end
|
121
|
+
|
122
|
+
# Returns true if a connection have been established to the GoodData API
|
123
|
+
# and the login was successful.
|
124
|
+
def logged_in?
|
125
|
+
@status == :logged_in
|
126
|
+
end
|
127
|
+
|
128
|
+
# The connection will automatically be established once it's needed, which it
|
129
|
+
# usually is when either the user, get, post or delete method is called. If you
|
130
|
+
# want to force a connection (or a re-connect) you can use this method.
|
131
|
+
def connect!
|
132
|
+
connect
|
133
|
+
end
|
134
|
+
|
135
|
+
# Uploads a file to GoodData server via FTPS
|
136
|
+
def upload(file, dir = nil)
|
137
|
+
Net::FTPTLS.open('secure-di.gooddata.com', @username, @password) do |ftp|
|
138
|
+
ftp.passive = true
|
139
|
+
if dir then
|
140
|
+
begin ; ftp.mkdir dir ; rescue ; ensure ; ftp.chdir dir ; end
|
141
|
+
end
|
142
|
+
ftp.binary = true
|
143
|
+
ftp.put file
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
|
149
|
+
def ensure_connection
|
150
|
+
connect if @status == :not_connected
|
151
|
+
end
|
152
|
+
|
153
|
+
def connect
|
154
|
+
# GoodData.logger.info "Connecting to GoodData..."
|
155
|
+
@status = :connecting
|
156
|
+
authenticate
|
157
|
+
end
|
158
|
+
|
159
|
+
def authenticate
|
160
|
+
credentials = {
|
161
|
+
'postUserLogin' => {
|
162
|
+
'login' => @username,
|
163
|
+
'password' => @password,
|
164
|
+
'remember' => 1
|
165
|
+
}
|
166
|
+
}
|
167
|
+
|
168
|
+
@server = RestClient::Resource.new @url, :headers => {
|
169
|
+
:content_type => :json,
|
170
|
+
:accept => [ :json, :zip ],
|
171
|
+
:user_agent => GoodData.gem_version_string
|
172
|
+
}
|
173
|
+
|
174
|
+
GoodData.logger.debug "Logging in..."
|
175
|
+
@user = post(LOGIN_PATH, credentials, :dont_reauth => true)['userLogin']
|
176
|
+
refresh_token :dont_reauth => true # avoid infinite loop if refresh_token fails with 401
|
177
|
+
|
178
|
+
@status = :logged_in
|
179
|
+
end
|
180
|
+
|
181
|
+
def process_response(options = {})
|
182
|
+
begin
|
183
|
+
begin
|
184
|
+
response = yield
|
185
|
+
rescue RestClient::Unauthorized
|
186
|
+
raise $! if options[:dont_reauth]
|
187
|
+
refresh_token
|
188
|
+
response = yield
|
189
|
+
end
|
190
|
+
merge_cookies! response.cookies
|
191
|
+
content_type = response.headers[:content_type]
|
192
|
+
if content_type == "application/json" then
|
193
|
+
result = response.to_str == '""' ? {} : JSON.parse(response.to_str)
|
194
|
+
GoodData.logger.debug "Response: #{result.inspect}"
|
195
|
+
elsif content_type == "application/zip" then
|
196
|
+
result = response
|
197
|
+
GoodData.logger.debug "Response: a zipped stream"
|
198
|
+
elsif response.headers[:content_length].to_s == '0'
|
199
|
+
result = nil
|
200
|
+
else
|
201
|
+
raise "Unsupported response content type '%s':\n%s" % [ content_type, response.to_str[0..127] ]
|
202
|
+
end
|
203
|
+
result
|
204
|
+
rescue RestClient::Exception => e
|
205
|
+
GoodData.logger.debug "Response: #{e.response}"
|
206
|
+
raise $!
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def refresh_token(options = {})
|
211
|
+
GoodData.logger.debug "Getting authentication token..."
|
212
|
+
begin
|
213
|
+
get TOKEN_PATH, :dont_reauth => true # avoid infinite loop GET fails with 401
|
214
|
+
rescue RestClient::Unauthorized
|
215
|
+
raise $! if options[:dont_reauth]
|
216
|
+
authenticate
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module GoodData::Helpers
|
2
|
+
def home_directory
|
3
|
+
running_on_windows? ? ENV['USERPROFILE'] : ENV['HOME']
|
4
|
+
end
|
5
|
+
|
6
|
+
def running_on_windows?
|
7
|
+
RUBY_PLATFORM =~ /mswin32|mingw32/
|
8
|
+
end
|
9
|
+
|
10
|
+
def running_on_a_mac?
|
11
|
+
RUBY_PLATFORM =~ /-darwin\d/
|
12
|
+
end
|
13
|
+
|
14
|
+
def error(msg)
|
15
|
+
STDERR.puts(msg)
|
16
|
+
exit 1
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,558 @@
|
|
1
|
+
require 'iconv'
|
2
|
+
require 'fastercsv'
|
3
|
+
|
4
|
+
##
|
5
|
+
# Module containing classes that counter-part GoodData server-side meta-data
|
6
|
+
# elements, including the server-side data model.
|
7
|
+
#
|
8
|
+
module GoodData
|
9
|
+
module Model
|
10
|
+
# GoodData REST API categories
|
11
|
+
LDM_CTG = 'ldm'
|
12
|
+
LDM_MANAGE_CTG = 'ldm-manage'
|
13
|
+
|
14
|
+
# Model naming conventions
|
15
|
+
FIELD_PK = 'id'
|
16
|
+
FK_SUFFIX = '_id'
|
17
|
+
FACT_COLUMN_PREFIX = 'f_'
|
18
|
+
DATE_COLUMN_PREFIX = 'dt_'
|
19
|
+
TIME_COLUMN_PREFIX = 'tm_'
|
20
|
+
LABEL_COLUMN_PREFIX = 'nm_'
|
21
|
+
ATTRIBUTE_FOLDER_PREFIX = 'dim'
|
22
|
+
ATTRIBUTE_PREFIX = 'attr'
|
23
|
+
FACT_PREFIX = 'fact'
|
24
|
+
DATE_FACT_PREFIX = 'dt'
|
25
|
+
TIME_FACT_PREFIX = 'tm.dt'
|
26
|
+
TIME_ATTRIBUTE_PREFIX = 'attr.time'
|
27
|
+
FACT_FOLDER_PREFIX = 'ffld'
|
28
|
+
|
29
|
+
class << self
|
30
|
+
def add_dataset(title, columns, project = nil)
|
31
|
+
add_schema Schema.new('columns' => columns, 'title' => title), project
|
32
|
+
end
|
33
|
+
|
34
|
+
def add_schema(schema, project = nil)
|
35
|
+
unless schema.is_a?(Schema) || schema.is_a?(String) then
|
36
|
+
raise ArgumentError.new("Schema object or schema file path expected, got '#{schema}'")
|
37
|
+
end
|
38
|
+
schema = Schema.load schema unless schema.is_a? Schema
|
39
|
+
project = GoodData.project unless project
|
40
|
+
ldm_links = GoodData.get project.md[LDM_CTG]
|
41
|
+
ldm_uri = Links.new(ldm_links)[LDM_MANAGE_CTG]
|
42
|
+
GoodData.post ldm_uri, { 'manage' => { 'maql' => schema.to_maql_create } }
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_id(str)
|
46
|
+
Iconv.iconv('ascii//ignore//translit', 'utf-8', str) \
|
47
|
+
.to_s.gsub(/[^\w\d_]/, '').gsub(/^[\d_]*/, '').downcase
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
class MdObject
|
52
|
+
attr_accessor :name, :title
|
53
|
+
|
54
|
+
def visual
|
55
|
+
"TITLE \"#{title_esc}\""
|
56
|
+
end
|
57
|
+
|
58
|
+
def title_esc
|
59
|
+
title.gsub(/"/, "\\\"")
|
60
|
+
end
|
61
|
+
|
62
|
+
##
|
63
|
+
# Generates an identifier from the object name by transliterating
|
64
|
+
# non-Latin character and then dropping non-alphanumerical characters.
|
65
|
+
#
|
66
|
+
def identifier
|
67
|
+
@identifier ||= "#{self.type_prefix}.#{Model::to_id(name)}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
##
|
72
|
+
# Server-side representation of a local data set; includes connection point,
|
73
|
+
# attributes and labels, facts, folders and corresponding pieces of physical
|
74
|
+
# model abstractions.
|
75
|
+
#
|
76
|
+
class Schema < MdObject
|
77
|
+
attr_reader :fields
|
78
|
+
|
79
|
+
def self.load(file)
|
80
|
+
Schema.new JSON.load(open(file))
|
81
|
+
end
|
82
|
+
|
83
|
+
def initialize(config, title = nil)
|
84
|
+
@fields = []
|
85
|
+
config['title'] = title unless config['title']
|
86
|
+
raise 'Schema name not specified' unless config['title']
|
87
|
+
self.title = config['title']
|
88
|
+
self.config = config
|
89
|
+
end
|
90
|
+
|
91
|
+
def config=(config)
|
92
|
+
labels = []
|
93
|
+
config['columns'].each do |c|
|
94
|
+
add_attribute c if c['type'] == 'ATTRIBUTE'
|
95
|
+
add_fact c if c['type'] == 'FACT'
|
96
|
+
add_date c if c['type'] == 'DATE'
|
97
|
+
set_connection_point c if c['type'] == 'CONNECTION_POINT'
|
98
|
+
labels.push c if c['type'] == 'LABEL'
|
99
|
+
end
|
100
|
+
@connection_point = RecordsOf.new(nil, self) unless @connection_point
|
101
|
+
end
|
102
|
+
|
103
|
+
def title=(title)
|
104
|
+
@name = title
|
105
|
+
@title = title
|
106
|
+
end
|
107
|
+
|
108
|
+
def type_prefix ; 'dataset' ; end
|
109
|
+
|
110
|
+
def attributes; @attributes ||= {} ; end
|
111
|
+
def facts; @facts ||= {} ; end
|
112
|
+
def folders; @folders ||= {}; end
|
113
|
+
|
114
|
+
##
|
115
|
+
# Underlying fact table name
|
116
|
+
#
|
117
|
+
def table
|
118
|
+
@table ||= FACT_COLUMN_PREFIX + Model::to_id(name)
|
119
|
+
end
|
120
|
+
|
121
|
+
##
|
122
|
+
# Generates MAQL DDL script to drop this data set and included pieces
|
123
|
+
#
|
124
|
+
def to_maql_drop
|
125
|
+
maql = ""
|
126
|
+
[ attributes, facts ].each do |obj|
|
127
|
+
maql += obj.to_maql_drop
|
128
|
+
end
|
129
|
+
maql += "DROP {#{self.identifier}};\n"
|
130
|
+
end
|
131
|
+
|
132
|
+
##
|
133
|
+
# Generates MAQL DDL script to create this data set and included pieces
|
134
|
+
#
|
135
|
+
def to_maql_create
|
136
|
+
maql = "# Create the '#{self.title}' data set\n"
|
137
|
+
maql += "CREATE DATASET {#{self.identifier}} VISUAL (TITLE \"#{self.title}\");\n\n"
|
138
|
+
[ attributes, facts, { 1 => @connection_point } ].each do |objects|
|
139
|
+
objects.values.each do |obj|
|
140
|
+
maql += "# Create '#{obj.title}' and add it to the '#{self.title}' data set.\n"
|
141
|
+
maql += obj.to_maql_create
|
142
|
+
maql += "ALTER DATASET {#{self.identifier}} ADD {#{obj.identifier}};\n\n"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
folders_maql = "# Create folders\n"
|
146
|
+
folders.keys.each { |folder| folders_maql += folder.to_maql_create }
|
147
|
+
folders_maql + "\n" + maql + "SYNCHRONIZE {#{identifier}};\n"
|
148
|
+
end
|
149
|
+
|
150
|
+
# Load given file into a data set described by the given schema
|
151
|
+
#
|
152
|
+
def upload(path, project = nil)
|
153
|
+
path = path.path if path.respond_to? :path
|
154
|
+
project = GoodData.project unless project
|
155
|
+
|
156
|
+
# create a temporary zip file
|
157
|
+
dir = Dir.mktmpdir
|
158
|
+
Zip::ZipFile.open("#{dir}/upload.zip", Zip::ZipFile::CREATE) do |zip|
|
159
|
+
# TODO make sure schema columns match CSV column names
|
160
|
+
zip.get_output_stream('upload_info.json') { |f| f.puts JSON.pretty_generate(to_manifest) }
|
161
|
+
zip.get_output_stream('data.csv') do |f|
|
162
|
+
FasterCSV.foreach(path) { |row| f.puts row.to_csv }
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# upload it
|
167
|
+
GoodData.connection.upload "#{dir}/upload.zip", File.basename(dir)
|
168
|
+
FileUtils.rm_rf dir
|
169
|
+
|
170
|
+
# kick the load
|
171
|
+
pull = { 'pullIntegration' => File.basename(dir) }
|
172
|
+
link = project.md.links('etl')['pull']
|
173
|
+
GoodData.post link, pull
|
174
|
+
end
|
175
|
+
|
176
|
+
# Generates the SLI manifest describing the data loading
|
177
|
+
#
|
178
|
+
def to_manifest
|
179
|
+
{
|
180
|
+
'dataSetSLIManifest' => {
|
181
|
+
'parts' => fields.map { |f| f.to_manifest_part },
|
182
|
+
'dataSet' => self.identifier,
|
183
|
+
'file' => 'data.csv', # should be configurable
|
184
|
+
'csvParams' => {
|
185
|
+
'quoteChar' => '"',
|
186
|
+
'escapeChar' => '"',
|
187
|
+
'separatorChar' => ',',
|
188
|
+
'endOfLine' => "\n"
|
189
|
+
}
|
190
|
+
}
|
191
|
+
}
|
192
|
+
end
|
193
|
+
|
194
|
+
private
|
195
|
+
|
196
|
+
def add_attribute(column)
|
197
|
+
attribute = Attribute.new column, self
|
198
|
+
@fields << attribute
|
199
|
+
add_to_hash(self.attributes, attribute)
|
200
|
+
folders[AttributeFolder.new(attribute.folder)] = 1 if attribute.folder
|
201
|
+
end
|
202
|
+
|
203
|
+
def add_fact(column)
|
204
|
+
fact = Fact.new column, self
|
205
|
+
@fields << fact
|
206
|
+
add_to_hash(self.facts, fact)
|
207
|
+
folders[FactFolder.new(fact.folder)] = 1 if fact.folder
|
208
|
+
end
|
209
|
+
|
210
|
+
def add_date(column)
|
211
|
+
date = DateColumn.new column, self
|
212
|
+
date.parts.values.each { |p| @fields << p }
|
213
|
+
date.facts.each { |f| add_to_hash(self.facts, f) }
|
214
|
+
date.attributes.each { |a| add_to_hash(self.attributes, a) }
|
215
|
+
@fields << date
|
216
|
+
end
|
217
|
+
|
218
|
+
def set_connection_point(column)
|
219
|
+
@connection_point = RecordsOf.new column, self
|
220
|
+
@fields << @connection_point
|
221
|
+
end
|
222
|
+
|
223
|
+
def add_to_hash(hash, obj); hash[obj.identifier] = obj; end
|
224
|
+
end
|
225
|
+
|
226
|
+
##
|
227
|
+
# This is a base class for server-side LDM elements such as attributes, labels and
|
228
|
+
# facts
|
229
|
+
#
|
230
|
+
class Column < MdObject
|
231
|
+
attr_accessor :folder, :name, :title, :schema
|
232
|
+
|
233
|
+
def initialize(hash, schema)
|
234
|
+
raise ArgumentError.new("Schema must be provided, got #{schema.class}") unless schema.is_a? Schema
|
235
|
+
@name = hash['name'] || raise("Data set fields must have their names defined")
|
236
|
+
@title = hash['title'] || hash['name']
|
237
|
+
@folder = hash['folder']
|
238
|
+
@schema = schema
|
239
|
+
end
|
240
|
+
|
241
|
+
##
|
242
|
+
# Generates an identifier from the object name by transliterating
|
243
|
+
# non-Latin character and then dropping non-alphanumerical characters.
|
244
|
+
#
|
245
|
+
def identifier
|
246
|
+
@identifier ||= "#{self.type_prefix}.#{Model::to_id @schema.title}.#{Model::to_id name}"
|
247
|
+
end
|
248
|
+
|
249
|
+
def to_maql_drop
|
250
|
+
"DROP {#{self.identifier}};\n"
|
251
|
+
end
|
252
|
+
|
253
|
+
def visual
|
254
|
+
visual = super
|
255
|
+
visual += ", FOLDER {#{folder_prefix}.#{Model::to_id(folder)}}" if folder
|
256
|
+
visual
|
257
|
+
end
|
258
|
+
|
259
|
+
# Overriden to prevent long strings caused by the @schema attribute
|
260
|
+
#
|
261
|
+
def inspect
|
262
|
+
to_s.sub(/>$/, " @title=#{@title.inspect}, @name=#{@name.inspect}, @folder=#{@folder.inspect}," \
|
263
|
+
" @schema=#{@schema.to_s.sub(/>$/, ' @title=' + @schema.name.inspect + '>')}" \
|
264
|
+
">")
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
##
|
269
|
+
# GoodData attribute abstraction
|
270
|
+
#
|
271
|
+
class Attribute < Column
|
272
|
+
attr_reader :primary_label
|
273
|
+
|
274
|
+
def type_prefix ; ATTRIBUTE_PREFIX ; end
|
275
|
+
def folder_prefix; ATTRIBUTE_FOLDER_PREFIX; end
|
276
|
+
|
277
|
+
def initialize(hash, schema)
|
278
|
+
super hash, schema
|
279
|
+
@primary_label = Label.new hash, self, schema
|
280
|
+
end
|
281
|
+
|
282
|
+
def table
|
283
|
+
@table ||= "d_" + Model::to_id(@schema.name) + "_" + Model::to_id(name)
|
284
|
+
end
|
285
|
+
|
286
|
+
def key ; "#{Model::to_id(@name)}#{FK_SUFFIX}" ; end
|
287
|
+
|
288
|
+
def to_maql_create
|
289
|
+
"CREATE ATTRIBUTE {#{identifier}} VISUAL (#{visual})" \
|
290
|
+
+ " AS KEYS {#{table}.#{Model::FIELD_PK}} FULLSET;\n" \
|
291
|
+
+ @primary_label.to_maql_create
|
292
|
+
end
|
293
|
+
|
294
|
+
def to_manifest_part
|
295
|
+
{
|
296
|
+
'referenceKey' => 1,
|
297
|
+
'populates' => [ @primary_label.identifier ],
|
298
|
+
'mode' => 'FULL',
|
299
|
+
'columnName' => name
|
300
|
+
}
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
##
|
305
|
+
# GoodData display form abstraction. Represents a default representation
|
306
|
+
# of an attribute column or an additional representation defined in a LABEL
|
307
|
+
# field
|
308
|
+
#
|
309
|
+
class Label < Column
|
310
|
+
def type_prefix ; 'label' ; end
|
311
|
+
|
312
|
+
def initialize(hash, attribute, schema)
|
313
|
+
super hash, schema
|
314
|
+
@attribute = attribute
|
315
|
+
end
|
316
|
+
|
317
|
+
def to_maql_create
|
318
|
+
"ALTER ATTRIBUTE {#{@attribute.identifier}} ADD LABELS {#{identifier}}" \
|
319
|
+
+ " VISUAL (TITLE #{title.inspect}) AS {#{column}};\n"
|
320
|
+
end
|
321
|
+
|
322
|
+
def to_manifest_part
|
323
|
+
{
|
324
|
+
'populates' => [ identifier ],
|
325
|
+
'mode' => 'FULL',
|
326
|
+
'columnName' => name
|
327
|
+
}
|
328
|
+
end
|
329
|
+
|
330
|
+
def column
|
331
|
+
"#{@attribute.table}.#{LABEL_COLUMN_PREFIX}#{Model::to_id name}"
|
332
|
+
end
|
333
|
+
|
334
|
+
alias :inspect_orig :inspect
|
335
|
+
def inspect
|
336
|
+
inspect_orig.sub(/>$/, " @attribute=" + @attribute.to_s.sub(/>$/, " @name=#{@attribute.name}") + '>')
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
##
|
341
|
+
# A GoodData attribute that represents a data set's connection point or a data set
|
342
|
+
# without a connection point
|
343
|
+
#
|
344
|
+
class RecordsOf < Attribute
|
345
|
+
def initialize(column, schema)
|
346
|
+
if column then
|
347
|
+
super
|
348
|
+
else
|
349
|
+
@name = 'id'
|
350
|
+
@title = "Records of #{schema.name}"
|
351
|
+
@folder = nil
|
352
|
+
@schema = schema
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def table
|
357
|
+
@table ||= "f_" + Model::to_id(@schema.name)
|
358
|
+
end
|
359
|
+
|
360
|
+
def to_maql_create
|
361
|
+
maql = super
|
362
|
+
maql += "\n# Connect '#{self.title}' to all attributes of this data set\n"
|
363
|
+
@schema.attributes.values.each do |c|
|
364
|
+
maql += "ALTER ATTRIBUTE {#{c.identifier}} ADD KEYS " \
|
365
|
+
+ "{#{table}.#{c.key}};\n"
|
366
|
+
end
|
367
|
+
maql
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
##
|
372
|
+
# GoodData fact abstraction
|
373
|
+
#
|
374
|
+
class Fact < Column
|
375
|
+
def type_prefix ; FACT_PREFIX ; end
|
376
|
+
def column_prefix ; FACT_COLUMN_PREFIX ; end
|
377
|
+
def folder_prefix; FACT_FOLDER_PREFIX; end
|
378
|
+
|
379
|
+
def table
|
380
|
+
@schema.table
|
381
|
+
end
|
382
|
+
|
383
|
+
def column
|
384
|
+
@column ||= table + '.' + column_prefix + Model::to_id(name)
|
385
|
+
end
|
386
|
+
|
387
|
+
def to_maql_create
|
388
|
+
"CREATE FACT {#{self.identifier}} VISUAL (#{visual})" \
|
389
|
+
+ " AS {#{column}};\n"
|
390
|
+
end
|
391
|
+
|
392
|
+
def to_manifest_part
|
393
|
+
{
|
394
|
+
'populates' => [ identifier ],
|
395
|
+
'mode' => 'FULL',
|
396
|
+
'columnName' => column
|
397
|
+
}
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
##
|
402
|
+
# Reference to another data set
|
403
|
+
#
|
404
|
+
class Reference
|
405
|
+
def initialize(column, schema)
|
406
|
+
@name = column['name']
|
407
|
+
@reference = column['reference']
|
408
|
+
@schema_ref = column['schema_ref']
|
409
|
+
@schema = schema
|
410
|
+
end
|
411
|
+
|
412
|
+
##
|
413
|
+
# Generates an identifier of the referencing attribute using the
|
414
|
+
# schema name derived from schemaReference and column name derived
|
415
|
+
# from the reference key.
|
416
|
+
#
|
417
|
+
def identifier
|
418
|
+
@identifier ||= "#{ATTRIBUTE_PREFIX}.#{Model::to_id @schema_ref.title}.#{Model::to_id @reference}"
|
419
|
+
end
|
420
|
+
|
421
|
+
def key ; "#{Model::to_id @name}_id" ; end
|
422
|
+
|
423
|
+
def label_column
|
424
|
+
@column ||= "#{@schema.table}.#{LABEL_COLUMN_PREFIX Model::to_id(reference)}"
|
425
|
+
end
|
426
|
+
|
427
|
+
def to_maql_create
|
428
|
+
"ALTER ATTRIBUTE {#{self.identifier} ADD KEYS {#{@schema.table}.#{key}}"
|
429
|
+
end
|
430
|
+
|
431
|
+
def to_maql_drop
|
432
|
+
"ALTER ATTRIBUTE {#{self.identifier} DROP KEYS {#{@schema.table}.#{key}}"
|
433
|
+
end
|
434
|
+
|
435
|
+
def to_manifest_part
|
436
|
+
{
|
437
|
+
'populates' => [ identifier ],
|
438
|
+
'mode' => 'FULL',
|
439
|
+
'columnName' => label_column
|
440
|
+
}
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
##
|
445
|
+
# Fact representation of a date.
|
446
|
+
#
|
447
|
+
class DateFact < Fact
|
448
|
+
def column_prefix ; DATE_COLUMN_PREFIX ; end
|
449
|
+
def type_prefix ; DATE_FACT_PREFIX ; end
|
450
|
+
end
|
451
|
+
|
452
|
+
##
|
453
|
+
# Date as a reference to a date dimension
|
454
|
+
#
|
455
|
+
class DateReference < Reference
|
456
|
+
|
457
|
+
end
|
458
|
+
|
459
|
+
##
|
460
|
+
# Date field that's not connected to a date dimension
|
461
|
+
#
|
462
|
+
class DateAttribute < Attribute
|
463
|
+
def key ; "#{DATE_COLUMN_PREFIX}#{super}" ; end
|
464
|
+
end
|
465
|
+
|
466
|
+
##
|
467
|
+
# Fact representation of a time of a day
|
468
|
+
#
|
469
|
+
class TimeFact < Fact
|
470
|
+
def column_prefix ; TIME_COLUMN_PREFIX ; end
|
471
|
+
def type_prefix ; TIME_FACT_PREFIX ; end
|
472
|
+
end
|
473
|
+
|
474
|
+
##
|
475
|
+
# Time as a reference to a time-of-a-day dimension
|
476
|
+
#
|
477
|
+
class TimeReference < Reference
|
478
|
+
|
479
|
+
end
|
480
|
+
|
481
|
+
##
|
482
|
+
# Time field that's not connected to a time-of-a-day dimension
|
483
|
+
#
|
484
|
+
class TimeAttribute < Attribute
|
485
|
+
def type_prefix ; TIME_ATTRIBUTE_PREFIX ; end
|
486
|
+
def key ; "#{TIME_COLUMN_PREFIX}#{super}" ; end
|
487
|
+
def table ; @table ||= "#{super}_tm" ; end
|
488
|
+
end
|
489
|
+
|
490
|
+
##
|
491
|
+
# Date column. A container holding the following
|
492
|
+
# parts: date fact, a date reference or attribute and an optional time component
|
493
|
+
# that contains a time fact and a time reference or attribute.
|
494
|
+
#
|
495
|
+
class DateColumn
|
496
|
+
attr_reader :parts, :facts, :attributes
|
497
|
+
|
498
|
+
def initialize(column, schema)
|
499
|
+
@parts = {} ; @facts = [] ; @attributes = []
|
500
|
+
|
501
|
+
@facts << @parts[:date_fact] = DateFact.new(column, schema)
|
502
|
+
if column['schemaReference'] then
|
503
|
+
@parts[:date_ref] = DateReference.new column, schema
|
504
|
+
else
|
505
|
+
@attributes << @parts[:date_attr] = DateAttribute.new(column, schema)
|
506
|
+
end
|
507
|
+
if column['datetime'] then
|
508
|
+
puts "*** datetime"
|
509
|
+
@facts << @parts[:time_fact] = TimeFact.new(column, schema)
|
510
|
+
if column['schemaReference'] then
|
511
|
+
@parts[:time_ref] = TimeReference.new column, schema
|
512
|
+
else
|
513
|
+
@attributes << @parts[:time_attr] = TimeAttribute.new(column, schema)
|
514
|
+
end
|
515
|
+
end
|
516
|
+
end
|
517
|
+
|
518
|
+
def to_maql_create
|
519
|
+
@parts.values.map { |v| v.to_maql_create }.join "\n"
|
520
|
+
end
|
521
|
+
|
522
|
+
def to_maql_drop
|
523
|
+
@parts.values.map { |v| v.to_maql_drop }.join "\n"
|
524
|
+
end
|
525
|
+
end
|
526
|
+
|
527
|
+
##
|
528
|
+
# Base class for GoodData attribute and fact folder abstractions
|
529
|
+
#
|
530
|
+
class Folder < MdObject
|
531
|
+
def initialize(title)
|
532
|
+
@title = title
|
533
|
+
@name = title
|
534
|
+
end
|
535
|
+
|
536
|
+
def to_maql_create
|
537
|
+
"CREATE FOLDER {#{type_prefix}.#{Model::to_id(name)}}" \
|
538
|
+
+ " VISUAL (#{visual}) TYPE #{type};\n"
|
539
|
+
end
|
540
|
+
end
|
541
|
+
|
542
|
+
##
|
543
|
+
# GoodData attribute folder abstraction
|
544
|
+
#
|
545
|
+
class AttributeFolder < Folder
|
546
|
+
def type; "ATTRIBUTE"; end
|
547
|
+
def type_prefix; "dim"; end
|
548
|
+
end
|
549
|
+
|
550
|
+
##
|
551
|
+
# GoodData fact folder abstraction
|
552
|
+
#
|
553
|
+
class FactFolder < Folder
|
554
|
+
def type; "FACT"; end
|
555
|
+
def type_prefix; "ffld"; end
|
556
|
+
end
|
557
|
+
end
|
558
|
+
end
|