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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +36 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE +21 -0
  5. data/README.md +130 -0
  6. data/Rakefile +42 -0
  7. data/docs/DWH/Adapters/Adapter.html +3053 -0
  8. data/docs/DWH/Adapters/Athena.html +1704 -0
  9. data/docs/DWH/Adapters/Boolean.html +121 -0
  10. data/docs/DWH/Adapters/Druid.html +1626 -0
  11. data/docs/DWH/Adapters/DuckDb.html +2012 -0
  12. data/docs/DWH/Adapters/MySql.html +1704 -0
  13. data/docs/DWH/Adapters/OpenAuthorizable/ClassMethods.html +265 -0
  14. data/docs/DWH/Adapters/OpenAuthorizable.html +1102 -0
  15. data/docs/DWH/Adapters/Postgres.html +2000 -0
  16. data/docs/DWH/Adapters/Snowflake.html +1662 -0
  17. data/docs/DWH/Adapters/SqlServer.html +2084 -0
  18. data/docs/DWH/Adapters/Trino.html +1835 -0
  19. data/docs/DWH/Adapters.html +129 -0
  20. data/docs/DWH/AuthenticationError.html +142 -0
  21. data/docs/DWH/Behaviors.html +767 -0
  22. data/docs/DWH/Capabilities.html +748 -0
  23. data/docs/DWH/Column.html +1115 -0
  24. data/docs/DWH/ConfigError.html +143 -0
  25. data/docs/DWH/ConnectionError.html +143 -0
  26. data/docs/DWH/DWHError.html +138 -0
  27. data/docs/DWH/ExecutionError.html +143 -0
  28. data/docs/DWH/Factory.html +1133 -0
  29. data/docs/DWH/Functions/Arrays.html +505 -0
  30. data/docs/DWH/Functions/Dates.html +1644 -0
  31. data/docs/DWH/Functions/ExtractDatePart.html +804 -0
  32. data/docs/DWH/Functions/Nulls.html +377 -0
  33. data/docs/DWH/Functions.html +846 -0
  34. data/docs/DWH/Logger.html +258 -0
  35. data/docs/DWH/OAuthError.html +138 -0
  36. data/docs/DWH/Settings.html +658 -0
  37. data/docs/DWH/StreamingStats.html +804 -0
  38. data/docs/DWH/Table.html +1260 -0
  39. data/docs/DWH/TableStats.html +583 -0
  40. data/docs/DWH/TokenExpiredError.html +142 -0
  41. data/docs/DWH/UnsupportedCapability.html +135 -0
  42. data/docs/DWH.html +220 -0
  43. data/docs/_index.html +471 -0
  44. data/docs/class_list.html +54 -0
  45. data/docs/css/common.css +1 -0
  46. data/docs/css/full_list.css +58 -0
  47. data/docs/css/style.css +503 -0
  48. data/docs/file.README.html +210 -0
  49. data/docs/file.adapters.html +514 -0
  50. data/docs/file.creating-adapters.html +497 -0
  51. data/docs/file.getting-started.html +288 -0
  52. data/docs/file.usage.html +446 -0
  53. data/docs/file_list.html +79 -0
  54. data/docs/frames.html +22 -0
  55. data/docs/guides/adapters.md +445 -0
  56. data/docs/guides/creating-adapters.md +430 -0
  57. data/docs/guides/getting-started.md +225 -0
  58. data/docs/guides/usage.md +378 -0
  59. data/docs/index.html +210 -0
  60. data/docs/js/app.js +344 -0
  61. data/docs/js/full_list.js +242 -0
  62. data/docs/js/jquery.js +4 -0
  63. data/docs/method_list.html +2038 -0
  64. data/docs/top-level-namespace.html +110 -0
  65. data/lib/dwh/adapters/athena.rb +359 -0
  66. data/lib/dwh/adapters/druid.rb +267 -0
  67. data/lib/dwh/adapters/duck_db.rb +235 -0
  68. data/lib/dwh/adapters/my_sql.rb +235 -0
  69. data/lib/dwh/adapters/open_authorizable.rb +215 -0
  70. data/lib/dwh/adapters/postgres.rb +250 -0
  71. data/lib/dwh/adapters/snowflake.rb +489 -0
  72. data/lib/dwh/adapters/sql_server.rb +257 -0
  73. data/lib/dwh/adapters/trino.rb +213 -0
  74. data/lib/dwh/adapters.rb +363 -0
  75. data/lib/dwh/behaviors.rb +67 -0
  76. data/lib/dwh/capabilities.rb +39 -0
  77. data/lib/dwh/column.rb +79 -0
  78. data/lib/dwh/errors.rb +29 -0
  79. data/lib/dwh/factory.rb +125 -0
  80. data/lib/dwh/functions/arrays.rb +42 -0
  81. data/lib/dwh/functions/dates.rb +162 -0
  82. data/lib/dwh/functions/extract_date_part.rb +70 -0
  83. data/lib/dwh/functions/nulls.rb +31 -0
  84. data/lib/dwh/functions.rb +86 -0
  85. data/lib/dwh/logger.rb +50 -0
  86. data/lib/dwh/settings/athena.yml +77 -0
  87. data/lib/dwh/settings/base.yml +81 -0
  88. data/lib/dwh/settings/databricks.yml +51 -0
  89. data/lib/dwh/settings/druid.yml +59 -0
  90. data/lib/dwh/settings/duckdb.yml +44 -0
  91. data/lib/dwh/settings/mysql.yml +67 -0
  92. data/lib/dwh/settings/postgres.yml +30 -0
  93. data/lib/dwh/settings/redshift.yml +52 -0
  94. data/lib/dwh/settings/snowflake.yml +45 -0
  95. data/lib/dwh/settings/sqlserver.yml +80 -0
  96. data/lib/dwh/settings/trino.yml +77 -0
  97. data/lib/dwh/settings.rb +79 -0
  98. data/lib/dwh/streaming_stats.rb +69 -0
  99. data/lib/dwh/table.rb +105 -0
  100. data/lib/dwh/table_stats.rb +51 -0
  101. data/lib/dwh/version.rb +5 -0
  102. data/lib/dwh.rb +54 -0
  103. data/sig/dwh.rbs +4 -0
  104. 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