metabase_tarot 1.0.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 (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: []