sqlui 0.1.17 → 0.1.19

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: db5b12190e0cde95f8a0063703cf3e0398bd8cfa862f1122b90c4f95b721c1ce
4
- data.tar.gz: 0b9a4db845a28a5a40923e8107bf961472cf57928b197b2b610b4e957fd365aa
3
+ metadata.gz: 3e42ae906ab341a224ba0904cf74be9224a13e6284e2ead198a382926e84db34
4
+ data.tar.gz: bd8f33a32f30179057914b9f291ff4af084f6917d8123f2bba550d5974df9b6f
5
5
  SHA512:
6
- metadata.gz: 4e74d51eccdec8b53c8e9455f555a55ab2cde80ae3e800b11efbbc57eb8f86bdcb8528faa2b70bc3589c2205baa3f3a7a08d866548257d045de89a437199c75b
7
- data.tar.gz: 87b4226337f573e294e2f181dc9c0485bc1ab407f0585d6c3c7232c5d965f6d7315cd138656a40337169ce39a01c2f18ad16f2855f5e506db8ac0eb4222a70f0
6
+ metadata.gz: c1f4f3c0f0e180b6aea398da508f7bc551edd19fd77e24f60825bfb33c7f4130e1299770a160947d27854cc7dbd26d76fda6436528561b82720b0652815208dc
7
+ data.tar.gz: 1e275114bf9e63d2182c1a4a2a2390ebe97a14ad5b15d1b21ac0e276fbadce766203e42546cf09b6bbdaf9e0bc4c8abc451aa2756dc02334b2dc89f403ae6447
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.17
1
+ 0.1.19
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mysql2'
4
+
5
+ # Config for a single database.
6
+ class DatabaseConfig
7
+ attr_reader :display_name, :description, :url_path, :saved_path, :client_params, :database
8
+
9
+ def initialize(hash)
10
+ @display_name = hash[:display_name].strip
11
+ @description = hash[:description].strip
12
+ @url_path = hash[:url_path].strip
13
+ raise ArgumentError, 'url_path should start with a /' unless @url_path.start_with?('/')
14
+ raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
15
+
16
+ @saved_path = hash[:saved_path].strip
17
+ @client_params = hash[:client_params]
18
+ @database = @client_params[:database].strip
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
data/app/server.rb CHANGED
@@ -2,11 +2,11 @@
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'
9
7
  require_relative 'environment'
8
+ require_relative 'sql_parser'
9
+ require_relative 'sqlui_config'
10
10
 
11
11
  if ARGV.include?('-v') || ARGV.include?('--version')
12
12
  puts File.read('.version')
@@ -18,78 +18,249 @@ raise 'configuration file does not exist' unless File.exist?(ARGV[0])
18
18
 
19
19
  # SQLUI Sinatra server.
20
20
  class Server < Sinatra::Base
21
+ MAX_ROWS = 1_000
22
+
23
+ CONFIG = SqluiConfig.new(ARGV[0])
24
+
25
+ def initialize(app = nil, **_kwargs)
26
+ super
27
+ @config = Server::CONFIG
28
+ @resources_dir = File.join(File.expand_path('..', File.dirname(__FILE__)), 'client', 'resources')
29
+ end
30
+
21
31
  set :logging, true
22
32
  set :bind, '0.0.0.0'
23
33
  set :port, Environment.server_port
24
34
  set :env, Environment.server_env
25
35
 
26
- # A MySQL client. This needs to go away.
27
- class Client
28
- def initialize(params)
29
- @params = params
36
+ get '/-/health' do
37
+ status 200
38
+ body 'OK'
39
+ end
40
+
41
+ get "#{CONFIG.list_url_path}/?" do
42
+ erb :databases, locals: { config: @config }
43
+ end
44
+
45
+ CONFIG.database_configs.each do |database|
46
+ get database.url_path.to_s do
47
+ redirect "#{params[:database]}/app", 301
30
48
  end
31
49
 
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)
50
+ get "#{database.url_path}/app" do
51
+ @html ||= File.read(File.join(@resources_dir, 'sqlui.html'))
52
+ status 200
53
+ headers 'Content-Type': 'text/html'
54
+ body @html
55
+ end
56
+
57
+ get "#{database.url_path}/sqlui.css" do
58
+ @css ||= File.read(File.join(@resources_dir, 'sqlui.css'))
59
+ status 200
60
+ headers 'Content-Type': 'text/css'
61
+ body @css
62
+ end
63
+
64
+ get "#{database.url_path}/sqlui.js" do
65
+ @js ||= File.read(File.join(@resources_dir, 'sqlui.js'))
66
+ status 200
67
+ headers 'Content-Type': 'text/javascript'
68
+ body @js
69
+ end
70
+
71
+ get "#{database.url_path}/metadata" do
72
+ database_config = @config.database_config_for(url_path: database.url_path)
73
+ metadata = database_config.with_client do |client|
74
+ load_metadata(client, database_config.database, database_config.saved_path)
37
75
  end
38
- client.query(sql)
76
+ status 200
77
+ headers 'Content-Type': 'application/json'
78
+ body metadata.to_json
39
79
  end
40
- end
41
80
 
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
81
+ get "#{database.url_path}/query_file" do
82
+ return client_error('missing file param') unless params[:file]
83
+ return client_error('no such file') unless File.exist?(params[:file])
66
84
 
67
- get '/-/health' do
68
- status 200
69
- body 'OK'
70
- end
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
+ return client_error('missing sql') unless params[:sql]
99
+ return client_error('missing cursor') unless params[:cursor]
100
+
101
+ sql = SqlParser.find_statement_at_cursor(params[:sql], Integer(params[:cursor]))
102
+ raise "can't find query at cursor" unless sql
71
103
 
72
- get '/db/?' do
73
- erb :databases, locals: { databases: config['databases'] }
104
+ database_config = @config.database_config_for(url_path: database.url_path)
105
+ result = database_config.with_client do |client|
106
+ execute_query(client, sql)
107
+ end
108
+
109
+ status 200
110
+ headers 'Content-Type': 'application/json'
111
+ body result.to_json
112
+ end
74
113
  end
75
114
 
76
- get '/db/:database' do
77
- redirect "/db/#{params[:database]}/app", 301
115
+ private
116
+
117
+ def client_error(message, stacktrace: nil)
118
+ status(400)
119
+ headers('Content-Type': 'application/json')
120
+ body({ message: message, stacktrace: stacktrace }.compact.to_json)
78
121
  end
79
122
 
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]
123
+ def load_metadata(client, database, saved_path)
124
+ result = {
125
+ server: @config.name,
126
+ schemas: {},
127
+ saved: Dir.glob("#{saved_path}/*.sql").map do |path|
128
+ {
129
+ filename: File.basename(path),
130
+ description: File.readlines(path).take_while { |l| l.start_with?('--') }.map { |l| l.sub(/^-- */, '') }.join
131
+ }
132
+ end
133
+ }
134
+
135
+ where_clause = if database
136
+ "where table_schema = '#{database}'"
137
+ else
138
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
139
+ end
140
+ column_result = client.query(
141
+ <<~SQL
142
+ select
143
+ table_schema,
144
+ table_name,
145
+ column_name,
146
+ data_type,
147
+ character_maximum_length,
148
+ is_nullable,
149
+ column_key,
150
+ column_default,
151
+ extra
152
+ from information_schema.columns
153
+ #{where_clause}
154
+ order by table_schema, table_name, column_name, ordinal_position;
155
+ SQL
156
+ )
157
+ column_result.each do |row|
158
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
159
+ table_schema = row[:table_schema]
160
+ unless result[:schemas][table_schema]
161
+ result[:schemas][table_schema] = {
162
+ tables: {}
163
+ }
164
+ end
165
+ table_name = row[:table_name]
166
+ tables = result[:schemas][table_schema][:tables]
167
+ unless tables[table_name]
168
+ tables[table_name] = {
169
+ indexes: {},
170
+ columns: {}
171
+ }
172
+ end
173
+ columns = result[:schemas][table_schema][:tables][table_name][:columns]
174
+ column_name = row[:column_name]
175
+ columns[column_name] = {} unless columns[column_name]
176
+ column = columns[column_name]
177
+ column[:name] = column_name
178
+ column[:data_type] = row[:data_type]
179
+ column[:length] = row[:character_maximum_length]
180
+ column[:allow_null] = row[:is_nullable]
181
+ column[:key] = row[:column_key]
182
+ column[:default] = row[:column_default]
183
+ column[:extra] = row[:extra]
184
+ end
185
+
186
+ where_clause = if database
187
+ "where table_schema = '#{database}'"
188
+ else
189
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
190
+ end
191
+ stats_result = client.query(
192
+ <<~SQL
193
+ select
194
+ table_schema,
195
+ table_name,
196
+ index_name,
197
+ seq_in_index,
198
+ non_unique,
199
+ column_name
200
+ from information_schema.statistics
201
+ #{where_clause}
202
+ order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
203
+ SQL
204
+ )
205
+ stats_result.each do |row|
206
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
207
+ table_schema = row[:table_schema]
208
+ tables = result[:schemas][table_schema][:tables]
209
+ table_name = row[:table_name]
210
+ indexes = tables[table_name][:indexes]
211
+ index_name = row[:index_name]
212
+ indexes[index_name] = {} unless indexes[index_name]
213
+ index = indexes[index_name]
214
+ column_name = row[:column_name]
215
+ index[column_name] = {}
216
+ column = index[column_name]
217
+ column[:name] = index_name
218
+ column[:seq_in_index] = row[:seq_in_index]
219
+ column[:non_unique] = row[:non_unique]
220
+ column[:column_name] = row[:column_name]
221
+ end
222
+
223
+ result
85
224
  end
86
225
 
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]
226
+ def execute_query(client, sql)
227
+ result = client.query(sql, cast: false)
228
+ rows = result.map(&:values)
229
+ columns = result.first&.keys || []
230
+ # TODO: use field_types
231
+ column_types = columns.map { |_| 'string' }
232
+ unless rows.empty?
233
+ maybe_non_null_column_value_exemplars = columns.each_with_index.map do |_, index|
234
+ row = rows.find do |current|
235
+ !current[index].nil?
236
+ end
237
+ row.nil? ? nil : row[index]
238
+ end
239
+ column_types = maybe_non_null_column_value_exemplars.map do |value|
240
+ case value
241
+ when String, NilClass
242
+ 'string'
243
+ when Integer, Float
244
+ 'number'
245
+ when Date
246
+ 'date'
247
+ when Time
248
+ 'datetime'
249
+ when TrueClass, FalseClass
250
+ 'boolean'
251
+ else
252
+ # TODO: report an error
253
+ value.class.to_s
254
+ end
255
+ end
256
+ end
257
+ {
258
+ query: sql,
259
+ columns: columns,
260
+ column_types: column_types,
261
+ total_rows: rows.size,
262
+ rows: rows.take(MAX_ROWS)
263
+ }
93
264
  end
94
265
 
95
266
  run!
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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'database_config'
5
+
6
+ # App config including database configs.
7
+ class SqluiConfig
8
+ attr_reader :name, :list_url_path, :database_configs
9
+
10
+ def initialize(filename)
11
+ config = YAML.safe_load(ERB.new(File.read(filename)).result)
12
+ deep_symbolize!(config)
13
+ @name = fetch_non_empty_string(config, :name).strip
14
+ @list_url_path = fetch_non_empty_string(config, :list_url_path).strip
15
+ raise ArgumentError, 'list_url_path should start with a /' unless @list_url_path.start_with?('/')
16
+ if @list_url_path.length > 1 && @list_url_path.end_with?('/')
17
+ raise ArgumentError, 'list_url_path should not end with a /'
18
+ end
19
+
20
+ databases = config[:databases]
21
+ if databases.nil? || !databases.is_a?(Hash) || databases.empty?
22
+ raise ArgumentError, 'required parameter databases missing'
23
+ end
24
+
25
+ @database_configs = databases.map do |_, current|
26
+ DatabaseConfig.new(current)
27
+ end
28
+ end
29
+
30
+ def database_config_for(url_path:)
31
+ @database_configs.find { |database| database.url_path == url_path } || raise("no config found for path #{url_path}")
32
+ end
33
+
34
+ private
35
+
36
+ def fetch_non_empty_string(hash, key)
37
+ value = hash[key]
38
+ raise ArgumentError, "required parameter #{key} missing" if value.nil? || !value.is_a?(String) || value.strip.empty?
39
+
40
+ value.strip
41
+ end
42
+
43
+ def deep_symbolize!(object)
44
+ return unless object.is_a? Hash
45
+
46
+ object.transform_keys!(&:to_sym)
47
+ object.each_value { |child| deep_symbolize!(child) }
48
+ end
49
+ end
@@ -5,11 +5,13 @@
5
5
  <style>
6
6
  body {
7
7
  font-family: Helvetica;
8
+ margin: 0px;
8
9
  }
9
10
 
10
11
  h1 {
11
12
  font-size: 30px;
12
- margin-bottom: 30px;
13
+ margin: 10px;
14
+
13
15
  }
14
16
 
15
17
  .database a {
@@ -20,8 +22,6 @@
20
22
 
21
23
  .database h2 {
22
24
  margin: 0px;
23
- margin-top: 10px;
24
- margin-bottom: 0px;
25
25
  font-size: 20px;
26
26
  font-weight: bold;
27
27
  }
@@ -29,11 +29,12 @@
29
29
  .database p {
30
30
  margin: 0px;
31
31
  margin-top: 10px;
32
- padding-bottom: 20px;
33
32
  font-size: 16px;
34
33
  }
35
34
 
36
35
  .database {
36
+ margin: 0px;
37
+ padding: 10px;
37
38
  cursor: pointer;
38
39
  border-bottom: 1px solid #eeeeee;
39
40
  }
@@ -49,15 +50,15 @@
49
50
  </head>
50
51
 
51
52
  <body>
52
- <h1>Databases</h1>
53
- <% databases.values.each do |database| %>
54
- <div class="database" onclick="window.location='<%= "/db/#{database['url_path']}/app" %>'">
55
- <h2><%= database['name'] %></h2>
56
- <a href="/db/<%= database['url_path'] %>/app">query</a>
57
- <a href="/db/<%= database['url_path'] %>/app?tab=saved">saved</a>
58
- <a href="/db/<%= database['url_path'] %>/app?tab=structure">structure</a>
59
- <p>
60
- <%= database['description'] %>
53
+ <h1><% config.name %> Databases</h1>
54
+ <% config.database_configs.each do |database_config| %>
55
+ <div class="database" onclick="window.location='<%= "#{database_config.url_path}/app" %>'">
56
+ <h2 class='name'><%= database_config.display_name %></h2>
57
+ <a class='query-link' href="<%= database_config.url_path %>/app">query</a>
58
+ <a class='saved-link' href="<%= database_config.url_path %>/app?tab=saved">saved</a>
59
+ <a class='structure-link' href="<%= database_config.url_path %>/app?tab=structure">structure</a>
60
+ <p class='description'>
61
+ <%= database_config.description %>
61
62
  </p>
62
63
  </div>
63
64
  <% end %>