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 +4 -4
- data/.version +1 -1
- data/app/database_config.rb +30 -0
- data/app/server.rb +228 -57
- data/app/sql_parser.rb +17 -0
- data/app/sqlui_config.rb +49 -0
- data/app/views/databases.erb +14 -13
- data/client/resources/sqlui.js +262 -257
- metadata +8 -6
- data/app/sqlui.rb +0 -230
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3e42ae906ab341a224ba0904cf74be9224a13e6284e2ead198a382926e84db34
|
4
|
+
data.tar.gz: bd8f33a32f30179057914b9f291ff4af084f6917d8123f2bba550d5974df9b6f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c1f4f3c0f0e180b6aea398da508f7bc551edd19fd77e24f60825bfb33c7f4130e1299770a160947d27854cc7dbd26d76fda6436528561b82720b0652815208dc
|
7
|
+
data.tar.gz: 1e275114bf9e63d2182c1a4a2a2390ebe97a14ad5b15d1b21ac0e276fbadce766203e42546cf09b6bbdaf9e0bc4c8abc451aa2756dc02334b2dc89f403ae6447
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.1.
|
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
|
-
|
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
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
76
|
+
status 200
|
77
|
+
headers 'Content-Type': 'application/json'
|
78
|
+
body metadata.to_json
|
39
79
|
end
|
40
|
-
end
|
41
80
|
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
73
|
-
|
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
|
-
|
77
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
data/app/sqlui_config.rb
ADDED
@@ -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
|
data/app/views/databases.erb
CHANGED
@@ -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
|
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
|
53
|
-
<%
|
54
|
-
<div class="database" onclick="window.location='<%= "
|
55
|
-
<h2
|
56
|
-
<a href="
|
57
|
-
<a href="
|
58
|
-
<a href="
|
59
|
-
<p>
|
60
|
-
<%=
|
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 %>
|