sqlui 0.1.18 → 0.1.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f951e672b477e0fd4882c1aa4e42472975ac07fa6a2955eb4430bc1018ea6165
4
- data.tar.gz: d8e7da6fe289ed3e94322a3ea3d5ce461356cbfa73e2e2732bab9001a8d3af24
3
+ metadata.gz: f41becc73029aa0f4e09476edbe96d273ed59b6dfd778eed97728c9d8b957e8e
4
+ data.tar.gz: 845897a0a4ab1a39e2ee2e6b8e5688b2548c0f30a463ef48171bd4953c138c01
5
5
  SHA512:
6
- metadata.gz: 6769e3ee3c0b5330ca0b1ac1433370756838cec3187f0bd9822308704d31ee1608592199acb9b622edba8d45146c557e413d42c0a060bb9ace9b7653b43ad54c
7
- data.tar.gz: 4ef70417125cdb931c5f6edba2ef7986be7bda22f0f27c362d7bea97df81752133493e9507b941cbfe51261fa4e77aca85f4838354805a97d88e64f0240e452d
6
+ metadata.gz: 85a81eeb433be3d01e0ecbacc990515249cc620c50cb852639a15a35849d2a69461c5c975140c1cb3ba743ac5f719756c0d1c7c44eed6e869f368cd32fd7386c
7
+ data.tar.gz: 9ad8b11e30e9ef47689eb01a7eb961fe409a7a5b10b06f32cdab6ce16a1ac43adaf91ab5a2643f51095d50262309f67d1af09cf8b304d532668380105c65c1dc
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.18
1
+ 0.1.20
data/app/args.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Argument validation.
4
+ class Args
5
+ def self.fetch_non_empty_string(hash, key)
6
+ value = fetch_non_nil(hash, key, String)
7
+ raise ArgumentError, "required parameter #{key} empty" if value.strip.empty?
8
+
9
+ value
10
+ end
11
+
12
+ def self.fetch_non_empty_hash(hash, key)
13
+ value = fetch_non_nil(hash, key, Hash)
14
+ raise ArgumentError, "required parameter #{key} empty" if value.empty?
15
+
16
+ value
17
+ end
18
+
19
+ def self.fetch_non_nil(hash, key, *classes)
20
+ raise ArgumentError, "required parameter #{key} missing" unless hash.key?(key)
21
+
22
+ value = hash[key]
23
+ raise ArgumentError, "required parameter #{key} null" if value.nil?
24
+
25
+ if classes.size.positive? && !classes.find { |clazz| value.is_a?(clazz) }
26
+ raise ArgumentError, "required parameter #{key} not a #{classes[0].to_s.downcase}" if classes.size == 1
27
+
28
+ raise ArgumentError, "required parameter #{key} not #{classes.map(&:to_s).map(&:downcase).join(' or ')}"
29
+ end
30
+
31
+ value
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mysql2'
4
+ require_relative 'args'
5
+
6
+ # Config for a single database.
7
+ class DatabaseConfig
8
+ attr_reader :display_name, :description, :url_path, :saved_path, :client_params
9
+
10
+ def initialize(hash)
11
+ @display_name = Args.fetch_non_empty_string(hash, :display_name).strip
12
+ @description = Args.fetch_non_empty_string(hash, :description).strip
13
+ @url_path = Args.fetch_non_empty_string(hash, :url_path).strip
14
+ raise ArgumentError, 'url_path should start with a /' unless @url_path.start_with?('/')
15
+ raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
16
+
17
+ @saved_path = Args.fetch_non_empty_string(hash, :saved_path).strip
18
+ @client_params = Args.fetch_non_empty_hash(hash, :client_params)
19
+ end
20
+
21
+ def with_client(&block)
22
+ client = Mysql2::Client.new(@client_params)
23
+ result = block.call(client)
24
+ client.close
25
+ client = nil
26
+ result
27
+ ensure
28
+ client&.close
29
+ end
30
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads metadata from a database.
4
+ class DatabaseMetadata
5
+ def initialize
6
+ raise "can't instantiate #{self.class}"
7
+ end
8
+
9
+ class << self
10
+ def lookup(client, database_config)
11
+ result = load_columns(client, database_config)
12
+ load_stats(result, client, database_config)
13
+
14
+ result
15
+ end
16
+
17
+ private
18
+
19
+ def load_columns(client, database_config)
20
+ result = {}
21
+ database = database_config.client_params[:database]
22
+ where_clause = if database
23
+ "where table_schema = '#{database}'"
24
+ else
25
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
26
+ end
27
+ column_result = client.query(
28
+ <<~SQL
29
+ select
30
+ table_schema,
31
+ table_name,
32
+ column_name,
33
+ data_type,
34
+ character_maximum_length,
35
+ is_nullable,
36
+ column_key,
37
+ column_default,
38
+ extra
39
+ from information_schema.columns
40
+ #{where_clause}
41
+ order by table_schema, table_name, column_name, ordinal_position;
42
+ SQL
43
+ )
44
+ column_result.each do |row|
45
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
46
+ table_schema = row[:table_schema]
47
+ unless result[table_schema]
48
+ result[table_schema] = {
49
+ tables: {}
50
+ }
51
+ end
52
+ table_name = row[:table_name]
53
+ tables = result[table_schema][:tables]
54
+ unless tables[table_name]
55
+ tables[table_name] = {
56
+ indexes: {},
57
+ columns: {}
58
+ }
59
+ end
60
+ columns = result[table_schema][:tables][table_name][:columns]
61
+ column_name = row[:column_name]
62
+ columns[column_name] = {} unless columns[column_name]
63
+ column = columns[column_name]
64
+ column[:name] = column_name
65
+ column[:data_type] = row[:data_type]
66
+ column[:length] = row[:character_maximum_length]
67
+ column[:allow_null] = row[:is_nullable]
68
+ column[:key] = row[:column_key]
69
+ column[:default] = row[:column_default]
70
+ column[:extra] = row[:extra]
71
+ end
72
+ result
73
+ end
74
+
75
+ def load_stats(result, client, database_config)
76
+ database = database_config.client_params[:database]
77
+
78
+ where_clause = if database
79
+ "where table_schema = '#{database}'"
80
+ else
81
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
82
+ end
83
+ stats_result = client.query(
84
+ <<~SQL
85
+ select
86
+ table_schema,
87
+ table_name,
88
+ index_name,
89
+ seq_in_index,
90
+ non_unique,
91
+ column_name
92
+ from information_schema.statistics
93
+ #{where_clause}
94
+ order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
95
+ SQL
96
+ )
97
+ stats_result.each do |row|
98
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
99
+ table_schema = row[:table_schema]
100
+ tables = result[table_schema][:tables]
101
+ table_name = row[:table_name]
102
+ indexes = tables[table_name][:indexes]
103
+ index_name = row[:index_name]
104
+ indexes[index_name] = {} unless indexes[index_name]
105
+ index = indexes[index_name]
106
+ column_name = row[:column_name]
107
+ index[column_name] = {}
108
+ column = index[column_name]
109
+ column[:name] = index_name
110
+ column[:seq_in_index] = row[:seq_in_index]
111
+ column[:non_unique] = row[:non_unique]
112
+ column[:column_name] = row[:column_name]
113
+ end
114
+ end
115
+ end
116
+ end
data/app/deep.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deep extensions for Enumerable.
4
+ module Enumerable
5
+ def deep_transform_keys!(&block)
6
+ each { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
7
+ self
8
+ end
9
+
10
+ def deep_dup(result = {})
11
+ map do |value|
12
+ value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
13
+ end
14
+ result
15
+ end
16
+ end
17
+
18
+ # Deep extensions for Hash.
19
+ class Hash
20
+ def deep_transform_keys!(&block)
21
+ transform_keys!(&:to_s)
22
+ each_value { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
23
+ self
24
+ end
25
+
26
+ def deep_dup(result = {})
27
+ each do |key, value|
28
+ result[key] = value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
29
+ end
30
+ result
31
+ end
32
+
33
+ def deep_set(*path, value:)
34
+ raise ArgumentError, 'no path specified' if path.empty?
35
+
36
+ if path.size == 1
37
+ self[path[0]] = value
38
+ else
39
+ raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
40
+ raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
41
+
42
+ self.[](path[0]).deep_set(*path[1..], value: value)
43
+ end
44
+ end
45
+
46
+ def deep_delete(*path)
47
+ raise ArgumentError, 'no path specified' if path.empty?
48
+ raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
49
+
50
+ if path.size == 1
51
+ delete(path[0])
52
+ else
53
+ raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
54
+
55
+ self.[](path[0]).deep_delete(*path[1..])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Maps MySQL data types to type descriptions the client can use.
4
+ class MysqlTypes
5
+ def initialize
6
+ raise "can't instantiate #{self.class}"
7
+ end
8
+
9
+ class << self
10
+ def map_to_google_charts_types(types)
11
+ types.map do |type|
12
+ type.gsub!(/\([^)]*\)/, '').strip!
13
+ case type
14
+ when 'bool', 'boolean'
15
+ 'boolean'
16
+ when /double/, 'bigint', 'bit', 'dec', 'decimal', 'float', 'int', 'integer', 'mediumint', 'smallint', 'tinyint'
17
+ 'number'
18
+ when /char/, /binary/, /blob/, /text/, 'enum', 'set', /image/
19
+ 'string'
20
+ when 'date', 'year'
21
+ 'date'
22
+ when 'datetime', 'timestamp'
23
+ 'datetime'
24
+ when 'time'
25
+ 'timeofday'
26
+ else
27
+ puts "unexpected MySQL type: #{type}"
28
+ 'string'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
data/app/server.rb CHANGED
@@ -2,95 +2,172 @@
2
2
 
3
3
  require 'erb'
4
4
  require 'json'
5
- require 'mysql2'
6
5
  require 'sinatra/base'
7
- require_relative 'sqlui'
8
- require 'yaml'
6
+ require 'uri'
7
+ require_relative 'database_metadata'
9
8
  require_relative 'environment'
10
-
11
- if ARGV.include?('-v') || ARGV.include?('--version')
12
- puts File.read('.version')
13
- exit
14
- end
15
-
16
- raise 'you must specify a configuration file' unless ARGV.size == 1
17
- raise 'configuration file does not exist' unless File.exist?(ARGV[0])
9
+ require_relative 'mysql_types'
10
+ require_relative 'sql_parser'
11
+ require_relative 'sqlui'
18
12
 
19
13
  # SQLUI Sinatra server.
20
14
  class Server < Sinatra::Base
21
- set :logging, true
22
- set :bind, '0.0.0.0'
23
- set :port, Environment.server_port
24
- set :env, Environment.server_env
25
-
26
- # A MySQL client. This needs to go away.
27
- class Client
28
- def initialize(params)
29
- @params = params
15
+ def self.init_and_run(config, resources_dir)
16
+ set :logging, true
17
+ set :bind, '0.0.0.0'
18
+ set :port, Environment.server_port
19
+ set :env, Environment.server_env
20
+ set :raise_errors, false
21
+ set :show_exceptions, false
22
+
23
+ get '/-/health' do
24
+ status 200
25
+ body 'OK'
30
26
  end
31
27
 
32
- def query(sql)
33
- client = Thread.current.thread_variable_get(:client)
34
- unless client
35
- client = Mysql2::Client.new(@params)
36
- Thread.current.thread_variable_set(:client, client)
37
- end
38
- client.query(sql)
28
+ get "#{config.list_url_path}/?" do
29
+ erb :databases, locals: { config: config }
39
30
  end
40
- end
41
31
 
42
- config = YAML.safe_load(ERB.new(File.read(ARGV[0])).result)
43
- saved_path_root = config['saved_path']
44
- client_map = config['databases'].values.to_h do |database_config|
45
- client_params = {
46
- host: database_config['db_host'],
47
- port: database_config['db_port'] || 3306,
48
- username: database_config['db_username'],
49
- password: database_config['db_password'],
50
- database: database_config['db_database'],
51
- read_timeout: 10, # seconds
52
- write_timeout: 0, # seconds
53
- connect_timeout: 5 # seconds
54
- }
55
- client = Client.new(client_params)
56
- [
57
- database_config['url_path'],
58
- ::SQLUI.new(
59
- client: client,
60
- table_schema: database_config['db_database'],
61
- name: database_config['name'],
62
- saved_path: File.join(saved_path_root, database_config['saved_path'])
63
- )
64
- ]
65
- end
32
+ config.database_configs.each do |database|
33
+ get database.url_path.to_s do
34
+ redirect "#{database.url_path}/app", 301
35
+ end
66
36
 
67
- get '/-/health' do
68
- status 200
69
- body 'OK'
70
- end
37
+ get "#{database.url_path}/app" do
38
+ @html ||= File.read(File.join(resources_dir, 'sqlui.html'))
39
+ status 200
40
+ headers 'Content-Type': 'text/html'
41
+ body @html
42
+ end
71
43
 
72
- get '/db/?' do
73
- erb :databases, locals: { databases: config['databases'] }
74
- end
44
+ get "#{database.url_path}/sqlui.css" do
45
+ @css ||= File.read(File.join(resources_dir, 'sqlui.css'))
46
+ status 200
47
+ headers 'Content-Type': 'text/css'
48
+ body @css
49
+ end
75
50
 
76
- get '/db/:database' do
77
- redirect "/db/#{params[:database]}/app", 301
78
- end
51
+ get "#{database.url_path}/sqlui.js" do
52
+ @js ||= File.read(File.join(resources_dir, 'sqlui.js'))
53
+ status 200
54
+ headers 'Content-Type': 'text/javascript'
55
+ body @js
56
+ end
57
+
58
+ get "#{database.url_path}/metadata" do
59
+ database_config = config.database_config_for(url_path: database.url_path)
60
+ metadata = database_config.with_client do |client|
61
+ {
62
+ server: config.name,
63
+ schemas: DatabaseMetadata.lookup(client, database_config),
64
+ saved: Dir.glob("#{database_config.saved_path}/*.sql").map do |path|
65
+ comment_lines = File.readlines(path).take_while do |l|
66
+ l.start_with?('--')
67
+ end
68
+ description = comment_lines.map { |l| l.sub(/^-- */, '') }.join
69
+ {
70
+ filename: File.basename(path),
71
+ description: description
72
+ }
73
+ end
74
+ }
75
+ end
76
+ status 200
77
+ headers 'Content-Type': 'application/json'
78
+ body metadata.to_json
79
+ end
79
80
 
80
- get '/db/:database/:route' do
81
- response = client_map[params[:database]].get(params)
82
- status response[:status]
83
- headers 'Content-Type': response[:content_type]
84
- body response[:body]
81
+ get "#{database.url_path}/query_file" do
82
+ break client_error('missing file param') unless params[:file]
83
+ break client_error('no such file') unless File.exist?(params[:file])
84
+
85
+ database_config = config.database_config_for(url_path: database.url_path)
86
+ sql = File.read(File.join(database_config.saved_path, params[:file]))
87
+ result = database_config.with_client do |client|
88
+ execute_query(client, sql).tap { |r| r[:file] = params[:file] }
89
+ end
90
+
91
+ status 200
92
+ headers 'Content-Type': 'application/json'
93
+ body result.to_json
94
+ end
95
+
96
+ post "#{database.url_path}/query" do
97
+ params.merge!(JSON.parse(request.body.read, symbolize_names: true))
98
+ break client_error('missing sql') unless params[:sql]
99
+
100
+ sql = params[:sql]
101
+ selection = params[:selection]
102
+ if selection
103
+ selection = selection.split(':').map { |v| Integer(v) }
104
+
105
+ sql = if selection[0] == selection[1]
106
+ SqlParser.find_statement_at_cursor(params[:sql], selection[0])
107
+ else
108
+ params[:sql][selection[0], selection[1]]
109
+ end
110
+ break client_error("can't find query at selection") unless sql
111
+ end
112
+
113
+ database_config = config.database_config_for(url_path: database.url_path)
114
+ result = database_config.with_client do |client|
115
+ execute_query(client, sql)
116
+ end
117
+
118
+ result[:selection] = params[:selection]
119
+
120
+ status 200
121
+ headers 'Content-Type': 'application/json'
122
+ body result.to_json
123
+ end
124
+ end
125
+
126
+ error do |e|
127
+ status 500
128
+ headers 'Content-Type': 'application/json'
129
+ result = {
130
+ error: e.message,
131
+ stacktrace: e.backtrace.map { |b| b }.join("\n")
132
+ }
133
+ body result.to_json
134
+ end
135
+
136
+ run!
85
137
  end
86
138
 
87
- post '/db/:database/:route' do
88
- post_body = JSON.parse(request.body.read)
89
- response = client_map[params[:database]].post(params.merge(post_body))
90
- status response[:status]
91
- headers 'Content-Type': response[:content_type]
92
- body response[:body]
139
+ private
140
+
141
+ def client_error(message, stacktrace: nil)
142
+ status(400)
143
+ headers('Content-Type': 'application/json')
144
+ body({ error: message, stacktrace: stacktrace }.compact.to_json)
93
145
  end
94
146
 
95
- run!
147
+ def execute_query(client, sql)
148
+ if sql.include?(';')
149
+ results = sql.split(/(?<=;)/).map { |current| client.query(current) }
150
+ result = results[-1]
151
+ else
152
+ result = client.query(sql)
153
+ end
154
+ # NOTE: the call to result.field_types must go before any other interaction with the result. Otherwise you will
155
+ # get a seg fault. Seems to be a bug in Mysql2.
156
+ if result
157
+ column_types = MysqlTypes.map_to_google_charts_types(result.field_types)
158
+ rows = result.map(&:values)
159
+ columns = result.first&.keys || []
160
+ else
161
+ column_types = []
162
+ rows = []
163
+ columns = []
164
+ end
165
+ {
166
+ query: sql,
167
+ columns: columns,
168
+ column_types: column_types,
169
+ total_rows: rows.size,
170
+ rows: rows.take(Sqlui::MAX_ROWS)
171
+ }
172
+ end
96
173
  end
data/app/sql_parser.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Used to parse strings containing one or more SQL statements.
4
+ class SqlParser
5
+ def self.find_statement_at_cursor(sql, cursor)
6
+ parts_with_ranges = []
7
+ sql.scan(/[^;]*;[ \n]*/) { |part| parts_with_ranges << [part, 0, part.size] }
8
+ parts_with_ranges.inject(0) do |pos, current|
9
+ current[1] += pos
10
+ current[2] += pos
11
+ end
12
+ part_with_range = parts_with_ranges.find do |current|
13
+ cursor >= current[1] && cursor < current[2]
14
+ end || parts_with_ranges[-1]
15
+ part_with_range[0].strip
16
+ end
17
+ end