metabase_tarot 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/bin/tarot +197 -0
  3. data/lib/tarot.rb +307 -0
  4. metadata +45 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd886182a092af7e593d482ddea5460b9e7197440a740cb364c09074f29ba7c3
4
+ data.tar.gz: 5974f750ce4309063b7e30ec19415ad855da958cd3fec0bc9b089ab7878791d5
5
+ SHA512:
6
+ metadata.gz: cf750b934c4b05fa9d804b3bcfc84c0ebee5f1085c9280c653b668da45febd4fdd2ee0ac52303693d97bf78db84df62d05860c5fb520692f22e58aab18a75fb4
7
+ data.tar.gz: c6494b6d00ac88a3a9feb9f7020eb190c7b2356f659fbbfab93786ccd7da1254b4d301a670890c74510c223e14e74f05d09fe4b7cd8165b3f43e7ed95e7523e8
data/bin/tarot ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env ruby
2
+ require 'tarot'
3
+ require 'fileutils'
4
+
5
+ SUBCOMMAND_NAMES = %w[run columns new console]
6
+
7
+ if ARGV.length == 0
8
+ puts "Tarot will give you the answer! But first you have to give tarot the question (#{SUBCOMMAND_NAMES.join(', ')})"
9
+ exit(1)
10
+ end
11
+
12
+ # https://stackoverflow.com/questions/16323571/measure-the-distance-between-two-strings-with-ruby
13
+ def levenshtein_distance(s, t)
14
+ m = s.length
15
+ n = t.length
16
+ return m if n == 0
17
+ return n if m == 0
18
+ d = Array.new(m+1) {Array.new(n+1)}
19
+
20
+ (0..m).each {|i| d[i][0] = i}
21
+ (0..n).each {|j| d[0][j] = j}
22
+ (1..n).each do |j|
23
+ (1..m).each do |i|
24
+ d[i][j] = if s[i-1] == t[j-1] # adjust index into string
25
+ d[i-1][j-1] # no operation required
26
+ else
27
+ [ d[i-1][j]+1, # deletion
28
+ d[i][j-1]+1, # insertion
29
+ d[i-1][j-1]+1, # substitution
30
+ ].min
31
+ end
32
+ end
33
+ end
34
+ d[m][n]
35
+ end
36
+
37
+ def pretty_print_databases
38
+ puts <<~TEXT
39
+ Available databases:
40
+ #{Tarot.database_names.join("\n")}
41
+ TEXT
42
+ end
43
+
44
+ def pretty_print_array(array)
45
+ array.each_slice(3) do |slice|
46
+ puts slice.map { |item| item.ljust(40) }.join
47
+ end
48
+ end
49
+
50
+ def demand_argument(index, name)
51
+ return ARGV[index] if ARGV.count > index
52
+
53
+ puts "Missing argument #{index}: #{name}"
54
+ exit(1)
55
+ end
56
+
57
+ def require_tarot_spec
58
+ path = "#{Dir.pwd}/tarot_spec.rb"
59
+ if !File.exist?("#{Dir.pwd}/tarot_spec.rb")
60
+ puts "No tarot_spec.rb found on #{Dir.pwd}. To create it on this directory, type: tarot new ."
61
+ exit(1)
62
+ end
63
+ require_relative path
64
+ end
65
+
66
+ def retrieve_database(database_name)
67
+ begin
68
+ database = Tarot.database(database_name)
69
+ rescue Tarot::DatabaseNotFound
70
+ pretty_print_databases
71
+ puts "\nNot found: #{database_name}"
72
+ did_you_mean(database_name, Tarot.database_names)
73
+ exit(1)
74
+ end
75
+ end
76
+
77
+ def sort_possibilities(input, possibilities)
78
+ possibilities.sort_by { |possibility| levenshtein_distance(input, possibility) }
79
+ end
80
+
81
+ def did_you_mean(input, possibilities)
82
+ return if possibilities.empty?
83
+
84
+ sorted_possibilities = sort_possibilities(input, possibilities)
85
+ closest_match = sorted_possibilities.first
86
+ distance = levenshtein_distance(input, closest_match)
87
+
88
+ return if distance > (input.length / 2)
89
+
90
+ puts "Did you mean? #{closest_match}"
91
+ end
92
+
93
+ case ARGV[0]
94
+ when 'run'
95
+ path = demand_argument(1, 'consultation')
96
+ require_tarot_spec()
97
+ require_relative "#{Dir.pwd}/consultations/#{path}"
98
+ when 'new'
99
+ path = "#{Dir.pwd}/#{demand_argument(1, 'path')}"
100
+
101
+ if File.exist?("#{path}/tarot_spec.rb")
102
+ puts 'This already is a valid Tarot folder (tarot_spec.rb exists)'
103
+ exit(1)
104
+ end
105
+
106
+ FileUtils.mkdir_p(path)
107
+ File.open("#{path}/tarot_spec.rb", "w") do |file|
108
+ file.write(
109
+ <<~RUBY
110
+ require 'tarot'
111
+ include Tarot
112
+
113
+ Config.build do |config|
114
+ config.session_expire_days = 13
115
+ config.url = 'You have to configure the url!'
116
+ config.database_aliases = {
117
+ # my_database: 'The name they actually use on metabase'
118
+ }
119
+ end
120
+ RUBY
121
+ )
122
+ end
123
+
124
+ File.open("#{path}/.gitignore", "w") do |file|
125
+ file.write(
126
+ <<~TEXT
127
+ .secret_token
128
+ consultations/
129
+ TEXT
130
+ )
131
+ end
132
+
133
+ File.open("#{path}/Gemfile", "w") do |file|
134
+ file.write(
135
+ <<~TEXT
136
+ source 'https://rubygems.org'
137
+
138
+ ruby '3.0.0'
139
+ gem 'metabase_tarot'
140
+ TEXT
141
+ )
142
+ end
143
+
144
+ puts "New deck created! Remember to configure #{path}/tarot_spec.rb"
145
+ when 'consultation'
146
+ input = demand_argument(1, 'consultation name')
147
+ filepath = "consultations/#{input}.rb"
148
+ FileUtils.mkdir_p(File.dirname(filepath))
149
+
150
+ File.open(filepath, "w") do |file|
151
+ file.write(
152
+ <<~RUBY
153
+ consultation(__FILE__) do
154
+ {
155
+ query: db('mydatabase').query!(
156
+ <<~SQL
157
+ SELECT *
158
+ FROM yourtablehere
159
+ SQL
160
+ )
161
+ }
162
+ end
163
+ RUBY
164
+ )
165
+ end
166
+ when 'dbs'
167
+ require_tarot_spec
168
+ pretty_print_databases
169
+ when 'cols'
170
+ require_tarot_spec
171
+ database_name = demand_argument(1, 'database name')
172
+ table_name = demand_argument(2, 'table name')
173
+ database = retrieve_database(database_name)
174
+ begin
175
+ query = database.query!("SELECT * FROM #{table_name} LIMIT 1")
176
+ rescue Tarot::QueryError => e
177
+ puts "Query for retrieving columns went wrong, probably the table '#{table_name}' does not exist:"
178
+ puts e
179
+ exit(1)
180
+ end
181
+ pretty_print_array(query.first.keys)
182
+ when 'tables'
183
+ require_tarot_spec
184
+ database_name = demand_argument(1, 'database name')
185
+ database = retrieve_database(database_name)
186
+ tables = database.query!("SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'")
187
+ .map { |x| x.values.first }
188
+ .reject { |x| x.start_with?("pg_") || x.start_with?("sql_") }
189
+ pretty_print_array(tables)
190
+ when 'console'
191
+ require_tarot_spec
192
+ Tarot.print_cheatsheet
193
+ binding.irb
194
+ else
195
+ puts "Invalid command #{ARGV[0]}"
196
+ did_you_mean(ARGV[0], SUBCOMMAND_NAMES)
197
+ end
data/lib/tarot.rb ADDED
@@ -0,0 +1,307 @@
1
+ require 'uri'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'io/console'
5
+ require 'date'
6
+ require 'yaml'
7
+
8
+ # Módulo para interagir com a API do Metabase.
9
+ module Tarot
10
+ # Exceção base do Tarot para suprimir backtracing.
11
+ class TarotError < StandardError
12
+ def backtrace
13
+ end
14
+ end
15
+
16
+ # Módulo para interagir com a configuração do tarot local do usuário.
17
+ module Config
18
+ # Exceção levantada quando a configuração do usuário (localizado na tarot_spec.rb) falha.
19
+ class ConfigError < TarotError
20
+ end
21
+
22
+ module_function
23
+ # Estrutura para representar uma configuração de usuário.
24
+ Data = Struct.new(:session_expire_days, :url, :database_aliases)
25
+
26
+ # Usa um bloco, normalmente definido na tarot_spec.rb, para popular as configurações de usuário.
27
+ #
28
+ # @example
29
+ # Config.build do |config|
30
+ # config.session_expire_days = 13
31
+ # config.url = 'https://metabase.mycompany.com'
32
+ # config.database_aliases = {}
33
+ # end
34
+ #
35
+ # @yieldreturn [Hash] Bloco que recebe um Tarot::Config::Data e modifica seus valores.
36
+ # @return [void]
37
+ # @raise [ConfigError] Se alguma configuração estiver faltando após a execução do bloco.
38
+ def build(&block)
39
+ @data = Data.new()
40
+ block.call(@data)
41
+
42
+ if @data.session_expire_days.nil?
43
+ raise ConfigError, "Tarot config is missing 'session_expire_days'"
44
+ end
45
+
46
+ if @data.url.nil?
47
+ raise ConfigError, "Tarot config is missing 'url'"
48
+ end
49
+
50
+ if @data.database_aliases.nil?
51
+ raise ConfigError, "Tarot config is missing 'database_aliases'"
52
+ end
53
+
54
+ @data.session_expire_days.freeze
55
+ @data.url.freeze
56
+ @data.database_aliases.freeze
57
+
58
+ @built = true
59
+ end
60
+
61
+ # Retorna as configurações de usuário
62
+ # @return [Config::Data] Resultado da consulta.
63
+ # @raise [ConfigError] Se as configurações ainda não foram definidas via Config.build.
64
+ def data
65
+ return @data unless @built.nil?
66
+
67
+ raise ConfigError, 'Tarot is not properly configured, check tarot_spec.rb'
68
+ end
69
+ end
70
+
71
+ # Caminho para o arquivo que carrega o token de autenticação do usuário + a data de expiração do token
72
+ SECRET_TOKEN_PATH = "#{Dir.pwd}/.secret_token".freeze
73
+
74
+ # Exceção levantada quando a autenticação falha.
75
+ class LoginError < TarotError
76
+ end
77
+
78
+ # Exceção levantada quando uma database não é encontrada na API. Imprime na tela quais são as database disponíveis.
79
+ class DatabaseNotFound < TarotError
80
+ end
81
+
82
+ # Exceção levantada quando o endpoint de query retornou erros. Vem com informações sobre o erro que ocorreu, caso a API disponibilize.
83
+ class QueryError < TarotError
84
+ end
85
+
86
+ # Estrutura para representar uma database na API.
87
+ Database = Struct.new(:id, :name) do
88
+ def inspect
89
+ "<Metabase::Database id=#{id.inspect}, name=#{name.inspect}>"
90
+ end
91
+
92
+ def to_s
93
+ "'#{name}'(id #{id})"
94
+ end
95
+
96
+ # Mesmo que Tarot.query!(esse_banco, sql)
97
+ # Executa uma consulta SQL e retorna o resultado. Levanta erro conforme resposta da API.
98
+ # @param sql [String] Consulta SQL a ser executada.
99
+ # @return [Object] Resultado da consulta.
100
+ # @raise [QueryError] Se ocorrer um erro na consulta.
101
+ def query!(sql)
102
+ Tarot.query!(self, sql)
103
+ end
104
+
105
+ # Mesmo que Tarot.query(esse_banco, sql)
106
+ # Executa uma consulta SQL e retorna o resultado.
107
+ # @param sql [String] Consulta SQL a ser executada.
108
+ # @return [Object] Resultado da API contendo a resposta da consulta ou um JSON com os erros que ocorreram.
109
+ def query(sql)
110
+ Tarot.query(self, sql)
111
+ end
112
+ end
113
+
114
+ module_function
115
+
116
+ # Grava os resultados de um bloco em um arquivo YAML com o mesmo nome passado pelo argumento filepath (porém terminando em .yaml), incluindo metadados de quando foi gerado, e do script que gerou o arquivo.
117
+ #
118
+ # @example
119
+ # exemplos/test.rb
120
+ # consultation(__FILE__) do
121
+ # db('minha db').query!('SELECT * FROM plans WHERE id = 3;')
122
+ # end
123
+ # # Isto irá criar um arquivo 'exemplos/test.yaml' com os resultados do bloco e metadados.
124
+ #
125
+ # @param filepath [String] Caminho do arquivo de origem, cujo conteúdo será usado nos metadados. É esperado que se preencha com __FILE__.
126
+ # @yieldreturn [Hash] Bloco que retorna um hash com os dados a serem gravados.
127
+ # @return [void]
128
+ def consultation(filepath, &block)
129
+ target_file = filepath.gsub(/\.rb\z/, '.yaml')
130
+
131
+ File.write(target_file, YAML.dump({
132
+ metadata: {
133
+ generated_at: DateTime.now.iso8601,
134
+ generated_by: File.read(filepath)
135
+ },
136
+ results: block.call()
137
+ }))
138
+ puts "Recorded #{target_file}"
139
+ end
140
+
141
+ # Encontra uma database pelo nome/alias.
142
+ # @param name [String] Nome ou alias da database.
143
+ # @return [Database] A database encontrada.
144
+ # @raise [DatabaseNotFound] Se a database não for encontrada.
145
+ def database(name)
146
+ if Config.data.database_aliases.keys.include?(name.to_sym)
147
+ name = Config.data.database_aliases[name.to_sym]
148
+ end
149
+
150
+ result = databases.find { |d| d.name == name }
151
+
152
+ unless result
153
+ message = <<~TEXT
154
+
155
+ Database not found: #{name}
156
+ To see available databases, run: tarot dbs
157
+ TEXT
158
+
159
+ raise DatabaseNotFound, message
160
+ end
161
+
162
+ result
163
+ end
164
+
165
+ alias db database
166
+
167
+ # Retorna uma lista do nome de todos os nomes de databases disponíveis. Caso exista um alias para um nome, ele substituirá o nome.
168
+ # @return [String] String com o nomes das databases.
169
+ def database_names
170
+ names = Tarot.databases.map(&:name)
171
+ aliases = Tarot::Config.data.database_aliases.keys.map(&:to_s)
172
+
173
+ names.filter! do |name|
174
+ !Tarot::Config.data.database_aliases.values.include?(name)
175
+ end
176
+
177
+ (names + aliases)
178
+ end
179
+
180
+ # Retorna uma lista de todas as databases disponíveis.
181
+ # @return [Array<Database>] Array de objetos Database.
182
+ def databases
183
+ unless @databases
184
+ uri = URI.parse("#{Config.data.url}/api/database")
185
+ http = Net::HTTP.new(uri.hostname, uri.port)
186
+ http.use_ssl = true
187
+
188
+ request = Net::HTTP::Get.new(uri)
189
+ request['Accept'] = 'application/json'
190
+ request['X-Metabase-Session'] = fetch_token
191
+
192
+ response = http.request(request)
193
+ @databases = JSON.parse(response.body).map do |payload|
194
+ Database.new(payload['id'], payload['name'])
195
+ end
196
+ end
197
+ @databases
198
+ end
199
+
200
+ # Executa uma consulta SQL e retorna o resultado. Levanta erro conforme resposta da API.
201
+ # @param database [Database] Base de dados onde executar a consulta.
202
+ # @param sql [String] Consulta SQL a ser executada.
203
+ # @return [Object] Resultado da consulta.
204
+ # @raise [QueryError] Se ocorrer um erro na consulta.
205
+ def query!(database, sql)
206
+ result = query(database, sql)
207
+ raise QueryError, result['error'] if result.is_a?(Hash) && result['error']
208
+
209
+ result
210
+ end
211
+
212
+ # Executa uma consulta SQL e retorna o resultado.
213
+ # @param database [Database] Base de dados onde executar a consulta. Use Tarot.database('minah db') para conseguir esse objeto.
214
+ # @param sql [String] Consulta SQL a ser executada.
215
+ # @return [Object] Resultado da API contendo a resposta da consulta ou um JSON com os erros que ocorreram.
216
+ def query(database, sql)
217
+ uri = URI.parse("#{Config.data.url}/api/dataset/json")
218
+ http = Net::HTTP.new(uri.hostname, uri.port)
219
+ http.use_ssl = true
220
+
221
+ request = Net::HTTP::Post.new(uri,
222
+ 'Accept' => 'application/json',
223
+ 'X-Metabase-Session' => fetch_token
224
+ )
225
+ request.content_type = 'application/x-www-form-urlencoded'
226
+
227
+ params = {
228
+ 'query': JSON.dump({
229
+ 'type' => 'native',
230
+ 'database' => database.id,
231
+ 'parameters' => [],
232
+ 'native' => {
233
+ 'query' => sql,
234
+ 'template-tags' => {}
235
+ },
236
+ })
237
+ }
238
+ request.body = URI.encode_www_form(params)
239
+ response = http.request(request)
240
+ JSON.parse(response.body) rescue response.body
241
+ end
242
+
243
+ # Limpa o token de sessão, armazenado localmente.
244
+ def clear_token
245
+ File.write(SECRET_TOKEN_PATH, '{}')
246
+ end
247
+
248
+ # Imprime a cheatsheet na tela
249
+ def print_cheatsheet
250
+ puts <<~TEXT
251
+ Tarot Cheatsheet
252
+
253
+ ┌─────────────────────────┬────────────────────────────────────────────────────┐
254
+ │ Ver databases │ puts database_names │
255
+ │ Puxar database por nome | db('nome') │
256
+ │ Fazer query │ minha_db.query!('SELECT * FROM mytable') │
257
+ │ Limpar dados de sessão │ clear_token │
258
+ │ Ver essa mensagem │ print_cheatsheet │
259
+ └─────────────────────────┴────────────────────────────────────────────────────┘
260
+ TEXT
261
+ end
262
+
263
+ # Obtém o token de sessão do Metabase, solicitando ao usuário se necessário.
264
+ # @return [String] Token de sessão.
265
+ def fetch_token
266
+ token_json = File.exist?(SECRET_TOKEN_PATH) ? JSON.load(File.read(SECRET_TOKEN_PATH)) : {}
267
+ token = token_json['token']
268
+
269
+ if token.nil?
270
+ uri = URI("#{Config.data.url}/api/session")
271
+ http = Net::HTTP.new(uri.hostname, uri.port)
272
+ http.use_ssl = true
273
+
274
+ request = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
275
+ login = {}
276
+ puts 'Login to Metabase'
277
+ print 'Username: '
278
+ login['username'] = STDIN.gets.chomp
279
+ print 'Password: '
280
+ login['password'] = STDIN.noecho(&:gets).chomp
281
+ puts "\n\n"
282
+
283
+ request.body = JSON.dump(login)
284
+ begin
285
+ response = http.request(request)
286
+ rescue OpenSSL::SSL::SSLError
287
+ puts "#{Config.data.url} seems to be unreachable at the moment"
288
+ end
289
+
290
+ token = JSON.parse(response.body)['id']
291
+
292
+ raise(LoginError, 'Wrong credentials') if token.nil?
293
+
294
+ File.write(SECRET_TOKEN_PATH, JSON.dump(token: token, expire_at: DateTime.now + Config.data.session_expire_days))
295
+ puts 'Authorized! :>'
296
+ else
297
+ expire_at = DateTime.parse(token_json['expire_at'])
298
+ if DateTime.now >= expire_at
299
+ puts 'Your token expired'
300
+ clear_token
301
+ fetch_token
302
+ end
303
+ end
304
+
305
+ token
306
+ end
307
+ end
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: metabase_tarot
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Hikari Luz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-02-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Utility tool for connecting directly to Metabase via its API
14
+ email: hikaridesuyoo@gmail.com
15
+ executables:
16
+ - tarot
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - bin/tarot
21
+ - lib/tarot.rb
22
+ homepage:
23
+ licenses:
24
+ - GPL-3.0
25
+ metadata: {}
26
+ post_install_message:
27
+ rdoc_options: []
28
+ require_paths:
29
+ - lib
30
+ required_ruby_version: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: 3.0.0
35
+ required_rubygems_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ requirements: []
41
+ rubygems_version: 3.2.3
42
+ signing_key:
43
+ specification_version: 4
44
+ summary: Want to know something from Metabase? Tarot will give you the answer!
45
+ test_files: []