dwh 0.1.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +36 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/Rakefile +42 -0
- data/docs/DWH/Adapters/Adapter.html +3053 -0
- data/docs/DWH/Adapters/Athena.html +1704 -0
- data/docs/DWH/Adapters/Boolean.html +121 -0
- data/docs/DWH/Adapters/Druid.html +1626 -0
- data/docs/DWH/Adapters/DuckDb.html +2012 -0
- data/docs/DWH/Adapters/MySql.html +1704 -0
- data/docs/DWH/Adapters/OpenAuthorizable/ClassMethods.html +265 -0
- data/docs/DWH/Adapters/OpenAuthorizable.html +1102 -0
- data/docs/DWH/Adapters/Postgres.html +2000 -0
- data/docs/DWH/Adapters/Snowflake.html +1662 -0
- data/docs/DWH/Adapters/SqlServer.html +2084 -0
- data/docs/DWH/Adapters/Trino.html +1835 -0
- data/docs/DWH/Adapters.html +129 -0
- data/docs/DWH/AuthenticationError.html +142 -0
- data/docs/DWH/Behaviors.html +767 -0
- data/docs/DWH/Capabilities.html +748 -0
- data/docs/DWH/Column.html +1115 -0
- data/docs/DWH/ConfigError.html +143 -0
- data/docs/DWH/ConnectionError.html +143 -0
- data/docs/DWH/DWHError.html +138 -0
- data/docs/DWH/ExecutionError.html +143 -0
- data/docs/DWH/Factory.html +1133 -0
- data/docs/DWH/Functions/Arrays.html +505 -0
- data/docs/DWH/Functions/Dates.html +1644 -0
- data/docs/DWH/Functions/ExtractDatePart.html +804 -0
- data/docs/DWH/Functions/Nulls.html +377 -0
- data/docs/DWH/Functions.html +846 -0
- data/docs/DWH/Logger.html +258 -0
- data/docs/DWH/OAuthError.html +138 -0
- data/docs/DWH/Settings.html +658 -0
- data/docs/DWH/StreamingStats.html +804 -0
- data/docs/DWH/Table.html +1260 -0
- data/docs/DWH/TableStats.html +583 -0
- data/docs/DWH/TokenExpiredError.html +142 -0
- data/docs/DWH/UnsupportedCapability.html +135 -0
- data/docs/DWH.html +220 -0
- data/docs/_index.html +471 -0
- data/docs/class_list.html +54 -0
- data/docs/css/common.css +1 -0
- data/docs/css/full_list.css +58 -0
- data/docs/css/style.css +503 -0
- data/docs/file.README.html +210 -0
- data/docs/file.adapters.html +514 -0
- data/docs/file.creating-adapters.html +497 -0
- data/docs/file.getting-started.html +288 -0
- data/docs/file.usage.html +446 -0
- data/docs/file_list.html +79 -0
- data/docs/frames.html +22 -0
- data/docs/guides/adapters.md +445 -0
- data/docs/guides/creating-adapters.md +430 -0
- data/docs/guides/getting-started.md +225 -0
- data/docs/guides/usage.md +378 -0
- data/docs/index.html +210 -0
- data/docs/js/app.js +344 -0
- data/docs/js/full_list.js +242 -0
- data/docs/js/jquery.js +4 -0
- data/docs/method_list.html +2038 -0
- data/docs/top-level-namespace.html +110 -0
- data/lib/dwh/adapters/athena.rb +359 -0
- data/lib/dwh/adapters/druid.rb +267 -0
- data/lib/dwh/adapters/duck_db.rb +235 -0
- data/lib/dwh/adapters/my_sql.rb +235 -0
- data/lib/dwh/adapters/open_authorizable.rb +215 -0
- data/lib/dwh/adapters/postgres.rb +250 -0
- data/lib/dwh/adapters/snowflake.rb +489 -0
- data/lib/dwh/adapters/sql_server.rb +257 -0
- data/lib/dwh/adapters/trino.rb +213 -0
- data/lib/dwh/adapters.rb +363 -0
- data/lib/dwh/behaviors.rb +67 -0
- data/lib/dwh/capabilities.rb +39 -0
- data/lib/dwh/column.rb +79 -0
- data/lib/dwh/errors.rb +29 -0
- data/lib/dwh/factory.rb +125 -0
- data/lib/dwh/functions/arrays.rb +42 -0
- data/lib/dwh/functions/dates.rb +162 -0
- data/lib/dwh/functions/extract_date_part.rb +70 -0
- data/lib/dwh/functions/nulls.rb +31 -0
- data/lib/dwh/functions.rb +86 -0
- data/lib/dwh/logger.rb +50 -0
- data/lib/dwh/settings/athena.yml +77 -0
- data/lib/dwh/settings/base.yml +81 -0
- data/lib/dwh/settings/databricks.yml +51 -0
- data/lib/dwh/settings/druid.yml +59 -0
- data/lib/dwh/settings/duckdb.yml +44 -0
- data/lib/dwh/settings/mysql.yml +67 -0
- data/lib/dwh/settings/postgres.yml +30 -0
- data/lib/dwh/settings/redshift.yml +52 -0
- data/lib/dwh/settings/snowflake.yml +45 -0
- data/lib/dwh/settings/sqlserver.yml +80 -0
- data/lib/dwh/settings/trino.yml +77 -0
- data/lib/dwh/settings.rb +79 -0
- data/lib/dwh/streaming_stats.rb +69 -0
- data/lib/dwh/table.rb +105 -0
- data/lib/dwh/table_stats.rb +51 -0
- data/lib/dwh/version.rb +5 -0
- data/lib/dwh.rb +54 -0
- data/sig/dwh.rbs +4 -0
- metadata +231 -0
@@ -0,0 +1,235 @@
|
|
1
|
+
module DWH
|
2
|
+
module Adapters
|
3
|
+
# MySql Adapter. To use this adapter make sure you have the
|
4
|
+
# {https://github.com/brianmario/mysql2 MySql2 Gem} installed. You
|
5
|
+
# can also pass additional connection properties via {Adapter#extra_connection_params}
|
6
|
+
# config property.
|
7
|
+
#
|
8
|
+
# MySql concept of database maps to schema in this adapter. This is only important
|
9
|
+
# for the metadata methods where you want to pull up tables from a different
|
10
|
+
# database (aka schema).
|
11
|
+
#
|
12
|
+
# @example Connecting to Localhost
|
13
|
+
# Please use 127.0.0.1 when using a local docker instance to run MySQl.
|
14
|
+
# Otherwise the Gem will try to connect over unix socket.
|
15
|
+
#
|
16
|
+
# DWH.create(:mysql, { host: '127.0.0.1', databse: 'mydb', username: 'me', password: 'mypwd', client_name: 'Strata CLI'})
|
17
|
+
#
|
18
|
+
# @example Connecting with SSL
|
19
|
+
# DWH.create(:mysql, { host: '127.0.0.1', databse: 'mydb',
|
20
|
+
# username: 'me', password: 'mypwd', ssl: true}) # this will default ssl_mode to required
|
21
|
+
#
|
22
|
+
# @example Modify the SSL mode. All extra ssl config can be passed this way.
|
23
|
+
# DWH.create(:mysql, { host: '127.0.0.1', databse: 'mydb',
|
24
|
+
# username: 'me', password: 'mypwd', ssl: true,
|
25
|
+
# extra_connection_params: {ssl_mode: "verify"})
|
26
|
+
class MySql < Adapter
|
27
|
+
config :host, String, required: true, message: 'server host ip address or domain name'
|
28
|
+
config :port, Integer, required: false, default: 3306, message: 'port to connect to'
|
29
|
+
config :database, String, required: true, message: 'name of database to connect to'
|
30
|
+
config :username, String, required: true, message: 'connection username'
|
31
|
+
config :password, String, required: false, default: nil, message: 'connection password'
|
32
|
+
config :query_timeout, Integer, required: false, default: 3600, message: 'query execution timeout in seconds'
|
33
|
+
config :ssl, Boolean, required: false, default: false, message: 'use ssl'
|
34
|
+
config :client_name, String, required: false, default: 'DWH Ruby Gem', message: 'The name of the connecting app'
|
35
|
+
|
36
|
+
# (see Adapter#connection)
|
37
|
+
def connection
|
38
|
+
return @connection if @connection
|
39
|
+
|
40
|
+
set_default_ssl_mode_if_needed
|
41
|
+
|
42
|
+
properties = {
|
43
|
+
# Connection Settings
|
44
|
+
host: config[:host],
|
45
|
+
username: config[:username],
|
46
|
+
password: config[:password],
|
47
|
+
port: 3306,
|
48
|
+
database: config[:database],
|
49
|
+
|
50
|
+
# Timeout Settings
|
51
|
+
connect_timeout: 10,
|
52
|
+
read_timeout: config[:query_timeout],
|
53
|
+
connect_attrs: {
|
54
|
+
program: config[:client_name]
|
55
|
+
}
|
56
|
+
}.merge(extra_connection_params)
|
57
|
+
|
58
|
+
@connection = Mysql2::Client.new(properties)
|
59
|
+
rescue StandardError => e
|
60
|
+
raise ConfigError, e.message
|
61
|
+
end
|
62
|
+
|
63
|
+
# (see Adapter#test_connection)
|
64
|
+
def test_connection(raise_exception: false)
|
65
|
+
connection
|
66
|
+
true
|
67
|
+
rescue StandardError => e
|
68
|
+
raise ConnectionError, e.message if raise_exception
|
69
|
+
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
73
|
+
# (see Adapter#tables)
|
74
|
+
def tables(**qualifiers)
|
75
|
+
schema = qualifiers[:schema] || config[:database]
|
76
|
+
query = "
|
77
|
+
SELECT
|
78
|
+
t.table_name
|
79
|
+
FROM information_schema.tables t
|
80
|
+
WHERE t.table_schema = '#{schema}'
|
81
|
+
ORDER BY t.table_name
|
82
|
+
"
|
83
|
+
|
84
|
+
res = connection.query(query, as: :array)
|
85
|
+
res.to_a.flatten
|
86
|
+
end
|
87
|
+
|
88
|
+
# (see Adapter#stats)
|
89
|
+
def stats(table, date_column: nil, **qualifiers)
|
90
|
+
table = "#{qualifiers[:schema]}.#{table}" if qualifiers[:schema]
|
91
|
+
sql = <<-SQL
|
92
|
+
SELECT count(*) row_count
|
93
|
+
#{date_column.nil? ? nil : ", min(#{date_column}) date_start"}
|
94
|
+
#{date_column.nil? ? nil : ", max(#{date_column}) date_end"}
|
95
|
+
FROM #{table}
|
96
|
+
SQL
|
97
|
+
|
98
|
+
result = connection.query(sql)
|
99
|
+
|
100
|
+
TableStats.new(
|
101
|
+
row_count: result.first['row_count'],
|
102
|
+
date_start: result.first['date_start'],
|
103
|
+
date_end: result.first['date_end']
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
# (see Adapter#metadata)
|
108
|
+
def metadata(table, **qualifiers)
|
109
|
+
db_table = Table.new table, schema: qualifiers[:schema]
|
110
|
+
schema_where = db_table.schema ? " AND table_schema = '#{db_table.schema}'" : ''
|
111
|
+
|
112
|
+
sql = <<-SQL
|
113
|
+
SELECT column_name, data_type, character_maximum_length, numeric_precision,numeric_scale
|
114
|
+
FROM information_schema.columns
|
115
|
+
WHERE lower(table_name) = lower('#{db_table.physical_name}')
|
116
|
+
#{schema_where}
|
117
|
+
SQL
|
118
|
+
|
119
|
+
cols = execute(sql, format: :object)
|
120
|
+
cols.each do |col|
|
121
|
+
db_table << Column.new(
|
122
|
+
name: col['COLUMN_NAME'],
|
123
|
+
data_type: col['DATA_TYPE'],
|
124
|
+
precision: col['NUMERIC_PRECISION'],
|
125
|
+
scale: col['NUMERIC_SCALE'],
|
126
|
+
max_char_length: col['CHARACTER_MAXIMUM_LENGTH']
|
127
|
+
)
|
128
|
+
end
|
129
|
+
|
130
|
+
db_table
|
131
|
+
end
|
132
|
+
|
133
|
+
# (see Adapter#execute)
|
134
|
+
def execute(sql, format: :array, retries: 0)
|
135
|
+
begin
|
136
|
+
as_param = %i[array object].include?(format) ? format : :array
|
137
|
+
result = with_debug(sql) { with_retry(retries) { connection.query(sql, as: as_param) } }
|
138
|
+
rescue StandardError => e
|
139
|
+
raise ExecutionError, e.message
|
140
|
+
end
|
141
|
+
|
142
|
+
format = format.downcase if format.is_a?(String)
|
143
|
+
case format.to_sym
|
144
|
+
when :array, :object
|
145
|
+
result.to_a
|
146
|
+
when :csv
|
147
|
+
result_to_csv(result)
|
148
|
+
when :native
|
149
|
+
result
|
150
|
+
else
|
151
|
+
raise UnsupportedCapability, "Unsupported format: #{format} for this #{name}"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# (see Adapter#execute_stream)
|
156
|
+
def execute_stream(sql, io, stats: nil, retries: 0)
|
157
|
+
with_debug(sql) do
|
158
|
+
with_retry(retries) do
|
159
|
+
result = connection.query(sql, stream: true, as: :array, cache_rows: false)
|
160
|
+
io.write(CSV.generate_line(result.fields))
|
161
|
+
result.each do |row|
|
162
|
+
io.write(CSV.generate_line(row))
|
163
|
+
stats << row if stats
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
io.rewind
|
169
|
+
io
|
170
|
+
rescue StandardError => e
|
171
|
+
raise ExecutionError, e.message
|
172
|
+
end
|
173
|
+
|
174
|
+
# (see Adapter#stream)
|
175
|
+
def stream(sql, &block)
|
176
|
+
with_debug(sql) do
|
177
|
+
result = connection.query(sql, as: :array, cache_rows: false)
|
178
|
+
result.each do |row|
|
179
|
+
block.call(row)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Custom date truncation implementation. MySql doesn't offer
|
185
|
+
# a native function. We basially have to format it and convert back
|
186
|
+
# to date object.
|
187
|
+
# @see Dates#truncate_date
|
188
|
+
def truncate_date(unit, exp)
|
189
|
+
unit = unit.strip.downcase
|
190
|
+
|
191
|
+
case unit
|
192
|
+
when 'year'
|
193
|
+
"DATE(DATE_FORMAT(#{exp}, '%Y-01-01'))"
|
194
|
+
when 'quarter'
|
195
|
+
"DATE(DATE_ADD(DATE_FORMAT(#{exp}, '%Y-01-01'), INTERVAL (QUARTER(#{exp}) - 1) * 3 MONTH))"
|
196
|
+
when 'month'
|
197
|
+
"DATE(DATE_FORMAT(#{exp}, '%Y-%m-01'))"
|
198
|
+
when 'week'
|
199
|
+
gsk("#{settings[:week_start_day].downcase}_week_start_day")
|
200
|
+
.gsub(/@exp/i, exp)
|
201
|
+
when 'day', 'date'
|
202
|
+
"DATE(#{exp})"
|
203
|
+
when 'hour'
|
204
|
+
"TIMESTAMP(DATE_FORMAT(#{exp}, '%Y-%m-%d %H:00:00'))"
|
205
|
+
else
|
206
|
+
raise UnsupportedCapability, "Currently not supporting truncation at #{unit} level"
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
private
|
211
|
+
|
212
|
+
def set_default_ssl_mode_if_needed
|
213
|
+
return unless config[:ssl] && !extra_connection_params[:ssl_mode]
|
214
|
+
|
215
|
+
extra_connection_params[:sslmode] = 'required'
|
216
|
+
end
|
217
|
+
|
218
|
+
def valid_config?
|
219
|
+
super
|
220
|
+
require 'mysql2'
|
221
|
+
rescue LoadError
|
222
|
+
raise ConfigError, "Required 'MySql2' gem missing. Please add it to your Gemfile."
|
223
|
+
end
|
224
|
+
|
225
|
+
def result_to_csv(result)
|
226
|
+
CSV.generate do |csv|
|
227
|
+
csv << result.fields
|
228
|
+
result.each do |row|
|
229
|
+
csv << row
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'securerandom'
|
3
|
+
|
4
|
+
module DWH
|
5
|
+
module Adapters
|
6
|
+
# OpenAuthorizable aka OAuth module will add functionality
|
7
|
+
# to get and refresh access tokens for databases that supported
|
8
|
+
# OAuth.
|
9
|
+
#
|
10
|
+
# To use this module include it in your adapter and call the oauth_with
|
11
|
+
# class method.
|
12
|
+
#
|
13
|
+
# @example Endpoint that needs to use instance to generate
|
14
|
+
# oauth_with authorize: ->(adapter) { "url#{config[:val]}"}, tokenize: "http://blue.com"
|
15
|
+
#
|
16
|
+
# @example Get authorization_url
|
17
|
+
# adapter.authorization_url
|
18
|
+
# Then capture the code and gen tokens
|
19
|
+
#
|
20
|
+
# @example Generate acess tokens
|
21
|
+
# adapter.generate_oauth_tokens(code_from_authorization)
|
22
|
+
# # this will also apply the tokens
|
23
|
+
#
|
24
|
+
# @example Reuse cached tokens
|
25
|
+
# adapter.apply_oauth_tokens(access_token: 'myaccesstoken', refresh_token: 'rtoken', expires_at: Time.now)
|
26
|
+
module OpenAuthorizable
|
27
|
+
# rubcop:disable Style/DocumentationModule
|
28
|
+
module ClassMethods
|
29
|
+
def oauth_with(authorize:, tokenize:, default_scope: 'refresh_token')
|
30
|
+
@oauth_settings = { authorize: authorize, tokenize: tokenize, default_scope: default_scope }
|
31
|
+
end
|
32
|
+
|
33
|
+
def oauth_settings
|
34
|
+
raise OAuthError, 'Please configure oauth settings by calling oauth_with class method.' unless @oauth_settings
|
35
|
+
|
36
|
+
@oauth_settings
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.included(base)
|
41
|
+
base.extend(ClassMethods)
|
42
|
+
base.config :oauth_client_id, String, required: false, message: 'OAuth client_id'
|
43
|
+
base.config :oauth_client_secret, String, required: false, message: 'OAuth client_secret'
|
44
|
+
base.config :oauth_redirect_uri, String, required: false, message: 'OAuth redirect_uri'
|
45
|
+
base.config :oauth_scope, String, required: false, message: 'OAuth redirect_url'
|
46
|
+
end
|
47
|
+
|
48
|
+
# Generate authorization URL for user to visit
|
49
|
+
def authorization_url(state: SecureRandom.hex(16), scope: nil)
|
50
|
+
params = {
|
51
|
+
'response_type' => 'code',
|
52
|
+
'client_id' => oauth_client_id,
|
53
|
+
'redirect_uri' => oauth_redirect_uri,
|
54
|
+
'state' => state,
|
55
|
+
'scope' => scope || oauth_scope || oauth_settings[:default_scope]
|
56
|
+
}.compact
|
57
|
+
|
58
|
+
uri = URI(oauth_settings[:authorize])
|
59
|
+
uri.query = URI.encode_www_form(params)
|
60
|
+
uri.to_s
|
61
|
+
end
|
62
|
+
|
63
|
+
# You can reuse existing tokens that were saved outside
|
64
|
+
# of this app by passing it here. This could be tokens
|
65
|
+
# cached from a previous call to @see #generate_oauth_tokens
|
66
|
+
#
|
67
|
+
# param access_token [String] the access token
|
68
|
+
# @param refresh_token [String] optional refresh token
|
69
|
+
def apply_oauth_tokens(access_token:, refresh_token: nil, expires_at: nil)
|
70
|
+
@oauth_access_token = access_token
|
71
|
+
@oauth_refresh_token = refresh_token
|
72
|
+
@token_expires_at = expires_at
|
73
|
+
end
|
74
|
+
|
75
|
+
# Takes the given authorization code and generates new
|
76
|
+
# access and refresh tokens. It will also apply them.
|
77
|
+
# @param authorization_code [String] this code should come from
|
78
|
+
# the redirect that is captured from the #authorization_url
|
79
|
+
def generate_oauth_tokens(authorization_code)
|
80
|
+
params = {
|
81
|
+
grant_type: 'authorization_code',
|
82
|
+
code: authorization_code,
|
83
|
+
redirect_uri: oauth_redirect_uri
|
84
|
+
}
|
85
|
+
|
86
|
+
response = oauth_http_client.post(oauth_tokenization_url) do |req|
|
87
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
88
|
+
req.headers['Authorization'] = basic_auth_header
|
89
|
+
req.body = URI.encode_www_form(params)
|
90
|
+
end
|
91
|
+
oauth_token_response(response)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Refresh access token using refresh token
|
95
|
+
def refresh_access_token
|
96
|
+
raise AuthenticationError, 'No refresh token available' unless @oauth_refresh_token
|
97
|
+
|
98
|
+
params = {
|
99
|
+
grant_type: 'refresh_token',
|
100
|
+
refresh_token: @oauth_refresh_token
|
101
|
+
}
|
102
|
+
|
103
|
+
response = oauth_http_client.post(oauth_tokenization_url) do |req|
|
104
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
105
|
+
req.headers['Authorization'] = basic_auth_header
|
106
|
+
req.body = URI.encode_www_form(params)
|
107
|
+
end
|
108
|
+
|
109
|
+
oauth_token_response(response)
|
110
|
+
end
|
111
|
+
|
112
|
+
# This will return the current access_token or
|
113
|
+
# if it expired and refresh_token token is available
|
114
|
+
# it will generate a new token.
|
115
|
+
#
|
116
|
+
# @return [String] access token
|
117
|
+
# @raise [AuthenticationError]
|
118
|
+
def oauth_access_token
|
119
|
+
if token_expired? && @oauth_refresh_token
|
120
|
+
refresh_access_token
|
121
|
+
|
122
|
+
# return token unless exception was raised
|
123
|
+
@oauth_access_token
|
124
|
+
elsif @oauth_access_token
|
125
|
+
@oauth_access_token
|
126
|
+
else
|
127
|
+
raise AuthenticationError,
|
128
|
+
'Access token was never set. Either run the auth flow or set the tokens via apply_oauth_tokens method.'
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Check if we have a valid access token
|
133
|
+
def oauth_authenticated?
|
134
|
+
@oauth_access_token && !token_expired?
|
135
|
+
end
|
136
|
+
|
137
|
+
# Get current state of tokens
|
138
|
+
def oauth_token_info
|
139
|
+
{
|
140
|
+
access_token: @oauth_access_token,
|
141
|
+
refresh_token: @oauth_refresh_token,
|
142
|
+
expires_at: @token_expires_at,
|
143
|
+
expired: token_expired?,
|
144
|
+
authenticated: oauth_authenticated?
|
145
|
+
}
|
146
|
+
end
|
147
|
+
|
148
|
+
def validate_oauth_config
|
149
|
+
raise ConfigError, 'Missing config: oauth_client_id. Required for OAuth.' unless config[:oauth_client_id]
|
150
|
+
raise ConfigError, 'Missing config: oauth_client_secret. Required for OAuth.' unless config[:oauth_client_secret]
|
151
|
+
raise ConfigError, 'Missing config: oauth_redirect_url. Required for OAuth.' unless config[:oauth_redirect_uri]
|
152
|
+
|
153
|
+
oauth_settings
|
154
|
+
end
|
155
|
+
|
156
|
+
def oauth_settings
|
157
|
+
@oauth_settings ||= self.class.oauth_settings.transform_values do
|
158
|
+
it.is_a?(Proc) ? it.call(self) : it
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def oauth_tokenization_url
|
163
|
+
oauth_settings[:tokenize]
|
164
|
+
end
|
165
|
+
|
166
|
+
protected
|
167
|
+
|
168
|
+
def basic_auth_header
|
169
|
+
credentials = Base64.strict_encode64("#{config[:oauth_client_id]}:#{config[:oauth_client_secret]}")
|
170
|
+
"Basic #{credentials}"
|
171
|
+
end
|
172
|
+
|
173
|
+
def oauth_http_client
|
174
|
+
@oauth_http_client ||= Faraday.new(
|
175
|
+
headers: {
|
176
|
+
'Content-Type' => 'application/json',
|
177
|
+
'User-Agent' => config[:client_name]
|
178
|
+
}
|
179
|
+
)
|
180
|
+
end
|
181
|
+
|
182
|
+
# Override this method to handle provider-specific token response formats
|
183
|
+
def oauth_token_response(response)
|
184
|
+
case response.status
|
185
|
+
when 200..299
|
186
|
+
data = JSON.parse(response.body)
|
187
|
+
|
188
|
+
apply_oauth_tokens(access_token: data['access_token'],
|
189
|
+
refresh_token: data['refresh_token'] || @oauth_refresh_token)
|
190
|
+
|
191
|
+
# Calculate expiration time
|
192
|
+
expires_in = data['expires_in'] || 3600
|
193
|
+
@token_expires_at = Time.now + expires_in
|
194
|
+
|
195
|
+
{ success: true, data: data }
|
196
|
+
else
|
197
|
+
error_data = parse_error_response(response)
|
198
|
+
if error_data['error'] == 'invalid_grant' && @oauth_refresh_token
|
199
|
+
raise TokenExpiredError, "Potentially expired refresh token. #{error_data['message']}"
|
200
|
+
end
|
201
|
+
|
202
|
+
raise AuthenticationError, "Token request failed: #{error_data['error']} - #{error_data['message']}"
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
private
|
207
|
+
|
208
|
+
def parse_error_response(response)
|
209
|
+
JSON.parse(response.body)
|
210
|
+
rescue JSON::ParserError
|
211
|
+
{ 'error' => 'unknown', 'message' => response.body }
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,250 @@
|
|
1
|
+
module DWH
|
2
|
+
module Adapters
|
3
|
+
# Postgres adapter. Please ensure the pg gem is available before using this adapter.
|
4
|
+
# Generally, adapters should be created using {DWH::Factory#create DWH.create}. Where a configuration
|
5
|
+
# is passed in as options hash or argument list.
|
6
|
+
#
|
7
|
+
# @example Basic connection with required only options
|
8
|
+
# DWH.create(:postgres, {host: 'localhost', database: 'postgres',
|
9
|
+
# username: 'postgres'})
|
10
|
+
#
|
11
|
+
# @example Connection with cert based SSL connection
|
12
|
+
# DWH.create(:postgres, {host: 'localhost', database: 'postgres',
|
13
|
+
# username: 'postgres', ssl: true,
|
14
|
+
# extra_connection_params: { sslmode: 'require' })
|
15
|
+
#
|
16
|
+
# valid sslmodes: disable, prefer, require, verify-ca, verify-full
|
17
|
+
# For modes requiring Certs make sure you add the appropirate params
|
18
|
+
# to extra_connection_params. (ie sslrootcert, sslcert etc.)
|
19
|
+
#
|
20
|
+
# @example Connection sending custom application name
|
21
|
+
# DWH.create(:postgres, {host: 'localhost', database: 'postgres',
|
22
|
+
# username: 'postgres', application_name: "Strata CLI" })
|
23
|
+
class Postgres < Adapter
|
24
|
+
config :host, String, required: true, message: 'server host ip address or domain name'
|
25
|
+
config :port, Integer, required: false, default: 5432, message: 'port to connect to'
|
26
|
+
config :database, String, required: true, message: 'name of database to connect to'
|
27
|
+
config :schema, String, default: 'public', message: 'schema name. defaults to "public"'
|
28
|
+
config :username, String, required: true, message: 'connection username'
|
29
|
+
config :password, String, required: false, default: nil, message: 'connection password'
|
30
|
+
config :query_timeout, String, required: false, default: 3600, message: 'query execution timeout in seconds'
|
31
|
+
config :ssl, Boolean, required: false, default: false, message: 'use ssl'
|
32
|
+
config :client_name, String, required: false, default: 'DWH Ruby Gem', message: 'The name of the connecting app'
|
33
|
+
|
34
|
+
# (see Adapter#connection)
|
35
|
+
def connection
|
36
|
+
return @connection if @connection
|
37
|
+
|
38
|
+
set_default_ssl_mode_if_needed
|
39
|
+
|
40
|
+
properties = {
|
41
|
+
host: config[:host],
|
42
|
+
port: config[:port],
|
43
|
+
dbname: config[:database],
|
44
|
+
user: config[:username],
|
45
|
+
password: config[:password],
|
46
|
+
application_name: config[:client_name]
|
47
|
+
}.merge(extra_connection_params)
|
48
|
+
properties[:options] = "#{properties[:options]} -c statement_timeout=#{config[:query_timeout]}s"
|
49
|
+
|
50
|
+
@connection = PG.connect(properties)
|
51
|
+
|
52
|
+
# this could be comma separated list
|
53
|
+
@connection.exec("SET search_path TO #{config[:schema]}") if schema?
|
54
|
+
|
55
|
+
@connection
|
56
|
+
rescue StandardError => e
|
57
|
+
raise ConfigError, e.message
|
58
|
+
end
|
59
|
+
|
60
|
+
# (see Adapter#test_connection)
|
61
|
+
def test_connection(raise_exception: false)
|
62
|
+
connection
|
63
|
+
true
|
64
|
+
rescue StandardError => e
|
65
|
+
raise ConnectionError, e.message if raise_exception
|
66
|
+
|
67
|
+
false
|
68
|
+
end
|
69
|
+
|
70
|
+
# (see Adapter#tables)
|
71
|
+
def tables(**qualifiers)
|
72
|
+
sql = if schema? || qualifiers[:schema]
|
73
|
+
<<-SQL
|
74
|
+
SELECT table_name#{' '}
|
75
|
+
FROM information_schema.tables
|
76
|
+
WHERE table_schema in (#{qualified_schema_name(qualifiers)})
|
77
|
+
SQL
|
78
|
+
else
|
79
|
+
<<-SQL
|
80
|
+
SELECT table_name
|
81
|
+
FROM information_schema.tables
|
82
|
+
SQL
|
83
|
+
end
|
84
|
+
|
85
|
+
result = connection.exec(sql)
|
86
|
+
result.values.flatten
|
87
|
+
end
|
88
|
+
|
89
|
+
# (see Adapter#table?)
|
90
|
+
def table?(table_name)
|
91
|
+
tables.include?(table_name)
|
92
|
+
end
|
93
|
+
|
94
|
+
# (see Adapter#stats)
|
95
|
+
def stats(table, date_column: nil, **qualifiers)
|
96
|
+
table_name = qualifiers[:schema] ? "#{qualifiers[:schema]}.#{table}" : table
|
97
|
+
sql = <<-SQL
|
98
|
+
SELECT count(*) ROW_COUNT
|
99
|
+
#{date_column.nil? ? nil : ", min(#{date_column}) DATE_START"}
|
100
|
+
#{date_column.nil? ? nil : ", max(#{date_column}) DATE_END"}
|
101
|
+
FROM "#{table_name}"
|
102
|
+
SQL
|
103
|
+
|
104
|
+
result = connection.exec(sql)
|
105
|
+
TableStats.new(
|
106
|
+
row_count: result.first['row_count'],
|
107
|
+
date_start: result.first['date_start'],
|
108
|
+
date_end: result.first['date_end']
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
# (see Adapter#metadata)
|
113
|
+
def metadata(table, **qualifiers)
|
114
|
+
db_table = Table.new table, schema: qualifiers[:schema]
|
115
|
+
|
116
|
+
schema_where = ''
|
117
|
+
if db_table.schema.present?
|
118
|
+
schema_where = "AND table_schema = '#{db_table.schema}'"
|
119
|
+
elsif schema?
|
120
|
+
schema_where = "AND table_schema in (#{qualified_schema_name})"
|
121
|
+
end
|
122
|
+
|
123
|
+
sql = <<-SQL
|
124
|
+
SELECT column_name, data_type, character_maximum_length, numeric_precision,numeric_scale
|
125
|
+
FROM information_schema.columns
|
126
|
+
WHERE table_name = '#{db_table.physical_name}'
|
127
|
+
#{schema_where}
|
128
|
+
SQL
|
129
|
+
|
130
|
+
cols = execute(sql, format: 'object')
|
131
|
+
cols.each do |col|
|
132
|
+
db_table << Column.new(
|
133
|
+
name: col['column_name'],
|
134
|
+
data_type: col['data_type'],
|
135
|
+
precision: col['numeric_precision'],
|
136
|
+
scale: col['numeric_scale'],
|
137
|
+
max_char_length: col['character_maximum_length']
|
138
|
+
)
|
139
|
+
end
|
140
|
+
|
141
|
+
db_table
|
142
|
+
end
|
143
|
+
|
144
|
+
# True if the configuration was setup with a schema.
|
145
|
+
def schema?
|
146
|
+
config[:schema].present?
|
147
|
+
end
|
148
|
+
|
149
|
+
# (see Adapter#execute)
|
150
|
+
def execute(sql, format: :array, retries: 0)
|
151
|
+
begin
|
152
|
+
result = with_debug(sql) { with_retry(retries) { connection.exec(sql) } }
|
153
|
+
rescue StandardError => e
|
154
|
+
raise ExecutionError, e.message
|
155
|
+
end
|
156
|
+
|
157
|
+
format = format.downcase if format.is_a?(String)
|
158
|
+
case format.to_sym
|
159
|
+
when :array
|
160
|
+
result.values
|
161
|
+
when :object
|
162
|
+
result.to_a
|
163
|
+
when :csv
|
164
|
+
result_to_csv(result)
|
165
|
+
when :native
|
166
|
+
result
|
167
|
+
else
|
168
|
+
raise UnsupportedCapability, "Unsupported format: #{format} for this #{name}"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# (see Adapter#execute_stream)
|
173
|
+
def execute_stream(sql, io, stats: nil, retries: 0)
|
174
|
+
with_debug(sql) do
|
175
|
+
with_retry(retries) do
|
176
|
+
connection.exec(sql) do |result|
|
177
|
+
io.write(CSV.generate_line(result.fields))
|
178
|
+
result.each_row do |row|
|
179
|
+
stats << row unless stats.nil?
|
180
|
+
io.write(CSV.generate_line(row))
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
io.rewind
|
187
|
+
io
|
188
|
+
rescue StandardError => e
|
189
|
+
raise ExecutionError, e.message
|
190
|
+
end
|
191
|
+
|
192
|
+
# (see Adapter#stream)
|
193
|
+
def stream(sql, &block)
|
194
|
+
with_debug(sql) do
|
195
|
+
connection.exec(sql) do |result|
|
196
|
+
result.each_row do |row|
|
197
|
+
block.call(row)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Need to override default add method
|
204
|
+
# since postgres doesn't support quarter as an
|
205
|
+
# interval.
|
206
|
+
# @param unit [String] Should be one of day, month, quarter etc
|
207
|
+
# @param val [String, Integer] The number of days to add
|
208
|
+
# @param exp [String] The sql expresssion to modify
|
209
|
+
def date_add(unit, val, exp)
|
210
|
+
if unit.downcase.strip == 'quarter'
|
211
|
+
unit = 'months'
|
212
|
+
val = val.to_i * 3
|
213
|
+
end
|
214
|
+
gsk(:date_add)
|
215
|
+
.gsub(/@unit/i, unit)
|
216
|
+
.gsub(/@val/i, val.to_s)
|
217
|
+
.gsub(/@exp/i, exp)
|
218
|
+
end
|
219
|
+
|
220
|
+
def valid_config?
|
221
|
+
super
|
222
|
+
require 'pg'
|
223
|
+
rescue LoadError
|
224
|
+
raise ConfigError, "Required 'pg' gem missing. Please add it to your Gemfile."
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
|
229
|
+
def set_default_ssl_mode_if_needed
|
230
|
+
return unless config[:ssl] && !extra_connection_params[:sslmode]
|
231
|
+
|
232
|
+
extra_connection_params[:sslmode] = 'require'
|
233
|
+
end
|
234
|
+
|
235
|
+
def qualified_schema_name(qualifiers = {})
|
236
|
+
qs = qualifiers[:schema] || config[:schema]
|
237
|
+
@qualified_schema_name ||= qs.split(',').map { |s| "'#{s}'" }.join(',')
|
238
|
+
end
|
239
|
+
|
240
|
+
def result_to_csv(result)
|
241
|
+
CSV.generate do |csv|
|
242
|
+
csv << result.fields
|
243
|
+
result.each do |row|
|
244
|
+
csv << row.values # default is hash
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|