sqlui 0.1.40 → 0.1.42
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/app/args.rb +4 -0
- data/app/database_config.rb +11 -1
- data/app/database_metadata.rb +20 -22
- data/app/server.rb +70 -45
- data/app/sqlui.rb +1 -1
- data/app/sqlui_config.rb +1 -1
- data/client/resources/sqlui.css +9 -0
- data/client/resources/sqlui.html +3 -2
- data/client/resources/sqlui.js +164 -48
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1bf6c0615e6ef20a8ccdb39cb45a50dbc1ce2a5ed465f0c92de054d63577c27b
|
4
|
+
data.tar.gz: ff7a3de7803138292bf86594ce224c09557156189486212d5aee0b83447d8553
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9db790262959d363d28b658148931b3e98aedb03f4f728f3c0a268e111a6d9b038b591949680bc5abf3dcf1118652d0cc4489109a478c4527bdb951590e49e35
|
7
|
+
data.tar.gz: 9771a25e3c63baeeb5fc3a8d71b275496c96656f2f12fac2996d17b2fc8a82b1666932d1b8fc11757bf2b54666f03eec2173cbcb11de420e7e72be8e8a2609b5
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.1.
|
1
|
+
0.1.42
|
data/app/args.rb
CHANGED
@@ -24,6 +24,10 @@ class Args
|
|
24
24
|
fetch_optional(hash, key, Hash)
|
25
25
|
end
|
26
26
|
|
27
|
+
def self.fetch_optional_array(hash, key)
|
28
|
+
fetch_optional(hash, key, Array)
|
29
|
+
end
|
30
|
+
|
27
31
|
def self.fetch_non_nil(hash, key, *classes)
|
28
32
|
raise ArgumentError, "required parameter #{key} missing" unless hash.key?(key)
|
29
33
|
|
data/app/database_config.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'json'
|
3
4
|
require 'mysql2'
|
4
5
|
require 'set'
|
5
6
|
|
@@ -7,7 +8,7 @@ require_relative 'args'
|
|
7
8
|
|
8
9
|
# Config for a single database.
|
9
10
|
class DatabaseConfig
|
10
|
-
attr_reader :display_name, :description, :url_path, :saved_path, :table_aliases, :client_params
|
11
|
+
attr_reader :display_name, :description, :url_path, :joins, :saved_path, :table_aliases, :client_params
|
11
12
|
|
12
13
|
def initialize(hash)
|
13
14
|
@display_name = Args.fetch_non_empty_string(hash, :display_name).strip
|
@@ -17,6 +18,15 @@ class DatabaseConfig
|
|
17
18
|
raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
|
18
19
|
|
19
20
|
@saved_path = Args.fetch_non_empty_string(hash, :saved_path).strip
|
21
|
+
@joins = Args.fetch_optional_array(hash, :joins) || []
|
22
|
+
@joins.map do |join|
|
23
|
+
next if join.is_a?(Hash) &&
|
24
|
+
join.keys.size == 2 &&
|
25
|
+
join[:label].is_a?(String) && !join[:label].strip.empty? &&
|
26
|
+
join[:apply].is_a?(String) && !join[:apply].strip.empty?
|
27
|
+
|
28
|
+
raise ArgumentError, "invalid join #{join.to_json}"
|
29
|
+
end
|
20
30
|
@table_aliases = Args.fetch_optional_hash(hash, :table_aliases) || {}
|
21
31
|
@table_aliases = @table_aliases.each do |table, a|
|
22
32
|
raise ArgumentError, "invalid alias for table #{table} (#{a}), expected string" unless a.is_a?(String)
|
data/app/database_metadata.rb
CHANGED
@@ -38,18 +38,17 @@ class DatabaseMetadata
|
|
38
38
|
extra
|
39
39
|
from information_schema.columns
|
40
40
|
#{where_clause}
|
41
|
-
order by table_schema, table_name,
|
41
|
+
order by table_schema, table_name, ordinal_position;
|
42
42
|
SQL
|
43
43
|
)
|
44
|
-
column_result.each do |row|
|
45
|
-
|
46
|
-
table_schema = row[:table_schema]
|
44
|
+
column_result.to_a.each do |row|
|
45
|
+
table_schema = row.shift
|
47
46
|
unless result[table_schema]
|
48
47
|
result[table_schema] = {
|
49
48
|
tables: {}
|
50
49
|
}
|
51
50
|
end
|
52
|
-
table_name = row
|
51
|
+
table_name = row.shift
|
53
52
|
tables = result[table_schema][:tables]
|
54
53
|
unless tables[table_name]
|
55
54
|
tables[table_name] = {
|
@@ -58,16 +57,16 @@ class DatabaseMetadata
|
|
58
57
|
}
|
59
58
|
end
|
60
59
|
columns = result[table_schema][:tables][table_name][:columns]
|
61
|
-
column_name = row
|
60
|
+
column_name = row.shift
|
62
61
|
columns[column_name] = {} unless columns[column_name]
|
63
62
|
column = columns[column_name]
|
64
63
|
column[:name] = column_name
|
65
|
-
column[:data_type] = row
|
66
|
-
column[:length] = row
|
67
|
-
column[:allow_null] = row
|
68
|
-
column[:key] = row
|
69
|
-
column[:default] = row
|
70
|
-
column[:extra] = row
|
64
|
+
column[:data_type] = row.shift
|
65
|
+
column[:length] = row.shift
|
66
|
+
column[:allow_null] = row.shift
|
67
|
+
column[:key] = row.shift
|
68
|
+
column[:default] = row.shift
|
69
|
+
column[:extra] = row.shift
|
71
70
|
end
|
72
71
|
result
|
73
72
|
end
|
@@ -86,30 +85,29 @@ class DatabaseMetadata
|
|
86
85
|
table_schema,
|
87
86
|
table_name,
|
88
87
|
index_name,
|
88
|
+
column_name,
|
89
89
|
seq_in_index,
|
90
|
-
non_unique
|
91
|
-
column_name
|
90
|
+
non_unique
|
92
91
|
from information_schema.statistics
|
93
92
|
#{where_clause}
|
94
93
|
order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
|
95
94
|
SQL
|
96
95
|
)
|
97
96
|
stats_result.each do |row|
|
98
|
-
|
99
|
-
table_schema = row[:table_schema]
|
97
|
+
table_schema = row.shift
|
100
98
|
tables = result[table_schema][:tables]
|
101
|
-
table_name = row
|
99
|
+
table_name = row.shift
|
102
100
|
indexes = tables[table_name][:indexes]
|
103
|
-
index_name = row
|
101
|
+
index_name = row.shift
|
104
102
|
indexes[index_name] = {} unless indexes[index_name]
|
105
103
|
index = indexes[index_name]
|
106
|
-
column_name = row
|
104
|
+
column_name = row.shift
|
107
105
|
index[column_name] = {}
|
108
106
|
column = index[column_name]
|
109
107
|
column[:name] = index_name
|
110
|
-
column[:seq_in_index] = row
|
111
|
-
column[:non_unique] = row
|
112
|
-
column[:column_name] =
|
108
|
+
column[:seq_in_index] = row.shift
|
109
|
+
column[:non_unique] = row.shift
|
110
|
+
column[:column_name] = column_name
|
113
111
|
end
|
114
112
|
end
|
115
113
|
end
|
data/app/server.rb
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'base64'
|
4
|
+
require 'csv'
|
3
5
|
require 'erb'
|
4
6
|
require 'json'
|
5
7
|
require 'sinatra/base'
|
6
8
|
require 'uri'
|
9
|
+
require 'webrick/log'
|
7
10
|
require_relative 'database_metadata'
|
8
11
|
require_relative 'mysql_types'
|
9
12
|
require_relative 'sql_parser'
|
@@ -12,6 +15,11 @@ require_relative 'sqlui'
|
|
12
15
|
# SQLUI Sinatra server.
|
13
16
|
class Server < Sinatra::Base
|
14
17
|
def self.init_and_run(config, resources_dir)
|
18
|
+
Mysql2::Client.default_query_options[:as] = :array
|
19
|
+
Mysql2::Client.default_query_options[:cast_booleans] = true
|
20
|
+
Mysql2::Client.default_query_options[:database_timezone] = :utc
|
21
|
+
Mysql2::Client.default_query_options[:cache_rows] = false
|
22
|
+
|
15
23
|
set :logging, true
|
16
24
|
set :bind, '0.0.0.0'
|
17
25
|
set :port, config.port
|
@@ -19,6 +27,8 @@ class Server < Sinatra::Base
|
|
19
27
|
set :raise_errors, false
|
20
28
|
set :show_exceptions, false
|
21
29
|
|
30
|
+
logger = WEBrick::Log.new
|
31
|
+
|
22
32
|
get '/-/health' do
|
23
33
|
status 200
|
24
34
|
body 'OK'
|
@@ -58,6 +68,7 @@ class Server < Sinatra::Base
|
|
58
68
|
list_url_path: config.list_url_path,
|
59
69
|
schemas: DatabaseMetadata.lookup(client, database),
|
60
70
|
table_aliases: database.table_aliases,
|
71
|
+
joins: database.joins,
|
61
72
|
saved: Dir.glob("#{database.saved_path}/*.sql").to_h do |path|
|
62
73
|
contents = File.read(path)
|
63
74
|
comment_lines = contents.split("\n").take_while do |l|
|
@@ -85,43 +96,54 @@ class Server < Sinatra::Base
|
|
85
96
|
params.merge!(JSON.parse(request.body.read, symbolize_names: true))
|
86
97
|
break client_error('missing sql') unless params[:sql]
|
87
98
|
|
88
|
-
full_sql = params[:sql]
|
89
|
-
sql = params[:sql]
|
90
99
|
variables = params[:variables] || {}
|
91
|
-
|
92
|
-
selection = params[:selection]
|
93
|
-
if selection.include?('-')
|
94
|
-
# sort because the selection could be in either direction
|
95
|
-
selection = params[:selection].split('-').map { |v| Integer(v) }.sort
|
96
|
-
else
|
97
|
-
selection = Integer(selection)
|
98
|
-
selection = [selection, selection]
|
99
|
-
end
|
100
|
-
|
101
|
-
sql = if selection[0] == selection[1]
|
102
|
-
SqlParser.find_statement_at_cursor(params[:sql], selection[0])
|
103
|
-
else
|
104
|
-
full_sql[selection[0], selection[1]]
|
105
|
-
end
|
106
|
-
|
107
|
-
break client_error("can't find query at selection") unless sql
|
108
|
-
end
|
100
|
+
sql = find_selected_query(params[:sql], params[:selection])
|
109
101
|
|
110
102
|
result = database.with_client do |client|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
103
|
+
query_result = execute_query(client, variables, sql)
|
104
|
+
# NOTE: the call to result.field_types must go before other interaction with the result. Otherwise you will
|
105
|
+
# get a seg fault. Seems to be a bug in Mysql2.
|
106
|
+
# TODO: stream this and render results on the client as they are returned?
|
107
|
+
{
|
108
|
+
columns: query_result.fields,
|
109
|
+
column_types: MysqlTypes.map_to_google_charts_types(query_result.field_types),
|
110
|
+
total_rows: query_result.size,
|
111
|
+
rows: (query_result.to_a || []).take(Sqlui::MAX_ROWS)
|
112
|
+
}
|
115
113
|
end
|
116
114
|
|
117
115
|
result[:selection] = params[:selection]
|
118
|
-
result[:query] =
|
116
|
+
result[:query] = params[:sql]
|
119
117
|
|
120
118
|
status 200
|
121
119
|
headers 'Content-Type' => 'application/json; charset=utf-8'
|
122
120
|
body result.to_json
|
123
121
|
end
|
124
122
|
|
123
|
+
get "#{database.url_path}/download_csv" do
|
124
|
+
break client_error('missing sql') unless params[:sql]
|
125
|
+
|
126
|
+
sql = Base64.decode64(params[:sql]).force_encoding('UTF-8')
|
127
|
+
logger.info "sql: #{sql}"
|
128
|
+
variables = params.map { |k, v| k[0] == '_' ? [k, v] : nil }.compact.to_h
|
129
|
+
sql = find_selected_query(sql, params[:selection])
|
130
|
+
logger.info "sql: #{sql}"
|
131
|
+
|
132
|
+
content_type 'application/csv; charset=utf-8'
|
133
|
+
attachment 'result.csv'
|
134
|
+
status 200
|
135
|
+
|
136
|
+
database.with_client do |client|
|
137
|
+
query_result = execute_query(client, variables, sql)
|
138
|
+
stream do |out|
|
139
|
+
out << CSV::Row.new(query_result.fields, query_result.fields, header_row: true).to_s.strip
|
140
|
+
query_result.each do |row|
|
141
|
+
out << "\n#{CSV::Row.new(query_result.fields, row).to_s.strip}"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
125
147
|
get(%r{#{Regexp.escape(database.url_path)}/(query|graph|structure|saved)}) do
|
126
148
|
@html ||= File.read(File.join(resources_dir, 'sqlui.html'))
|
127
149
|
status 200
|
@@ -152,30 +174,33 @@ class Server < Sinatra::Base
|
|
152
174
|
body({ error: message, stacktrace: stacktrace }.compact.to_json)
|
153
175
|
end
|
154
176
|
|
155
|
-
def
|
177
|
+
def find_selected_query(full_sql, selection)
|
178
|
+
return full_sql unless selection
|
179
|
+
|
180
|
+
if selection.include?('-')
|
181
|
+
# sort because the selection could be in either direction
|
182
|
+
selection = selection.split('-').map { |v| Integer(v) }.sort
|
183
|
+
else
|
184
|
+
selection = Integer(selection)
|
185
|
+
selection = [selection, selection]
|
186
|
+
end
|
187
|
+
|
188
|
+
if selection[0] == selection[1]
|
189
|
+
SqlParser.find_statement_at_cursor(full_sql, selection[0])
|
190
|
+
else
|
191
|
+
full_sql[selection[0], selection[1]]
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def execute_query(client, variables, sql)
|
196
|
+
variables.each do |name, value|
|
197
|
+
client.query("SET @#{name} = #{value};")
|
198
|
+
end
|
156
199
|
queries = if sql.include?(';')
|
157
200
|
sql.split(/(?<=;)/).map(&:strip).reject(&:empty?)
|
158
201
|
else
|
159
202
|
[sql]
|
160
203
|
end
|
161
|
-
|
162
|
-
result = results[-1]
|
163
|
-
# NOTE: the call to result.field_types must go before any other interaction with the result. Otherwise you will
|
164
|
-
# get a seg fault. Seems to be a bug in Mysql2.
|
165
|
-
if result
|
166
|
-
column_types = MysqlTypes.map_to_google_charts_types(result.field_types)
|
167
|
-
rows = result.map(&:values)
|
168
|
-
columns = result.first&.keys || []
|
169
|
-
else
|
170
|
-
column_types = []
|
171
|
-
rows = []
|
172
|
-
columns = []
|
173
|
-
end
|
174
|
-
{
|
175
|
-
columns: columns,
|
176
|
-
column_types: column_types,
|
177
|
-
total_rows: rows.size,
|
178
|
-
rows: rows.take(Sqlui::MAX_ROWS)
|
179
|
-
}
|
204
|
+
queries.map { |current| client.query(current) }.last
|
180
205
|
end
|
181
206
|
end
|
data/app/sqlui.rb
CHANGED
data/app/sqlui_config.rb
CHANGED
@@ -11,7 +11,7 @@ class SqluiConfig
|
|
11
11
|
attr_reader :name, :port, :environment, :list_url_path, :database_configs
|
12
12
|
|
13
13
|
def initialize(filename, overrides = {})
|
14
|
-
config = YAML.safe_load(ERB.new(File.read(filename)).result).deep_merge!(overrides)
|
14
|
+
config = YAML.safe_load(ERB.new(File.read(filename)).result, aliases: true).deep_merge!(overrides)
|
15
15
|
config.deep_symbolize_keys!
|
16
16
|
@name = Args.fetch_non_empty_string(config, :name).strip
|
17
17
|
@port = Args.fetch_non_empty_int(config, :port)
|
data/client/resources/sqlui.css
CHANGED
@@ -218,6 +218,15 @@ p {
|
|
218
218
|
outline: none
|
219
219
|
}
|
220
220
|
|
221
|
+
.submit-dropdown-content-button.disabled {
|
222
|
+
color: #888 !important;
|
223
|
+
cursor: auto !important;
|
224
|
+
}
|
225
|
+
|
226
|
+
.submit-dropdown-content-button.disabled:hover {
|
227
|
+
background: none !important;
|
228
|
+
}
|
229
|
+
|
221
230
|
.status {
|
222
231
|
display: flex;
|
223
232
|
justify-content: center;
|
data/client/resources/sqlui.html
CHANGED
@@ -41,8 +41,9 @@
|
|
41
41
|
<input id="submit-dropdown-button-toggle" class="submit-dropdown-content-button" type="button" value="toggle default"></input>
|
42
42
|
</div>
|
43
43
|
<div class="submit-dropdown-content-section">
|
44
|
-
<input id="submit-dropdown-button-copy-csv" class="submit-dropdown-content-button" type="button" value="copy to clipboard (csv)"></input>
|
45
|
-
<input id="submit-dropdown-button-copy-tsv" class="submit-dropdown-content-button" type="button" value="copy to clipboard (tsv)"></input>
|
44
|
+
<input id="submit-dropdown-button-copy-csv" class="submit-dropdown-content-button disabled" type="button" value="copy to clipboard (csv)"></input>
|
45
|
+
<input id="submit-dropdown-button-copy-tsv" class="submit-dropdown-content-button disabled" type="button" value="copy to clipboard (tsv)"></input>
|
46
|
+
<input id="submit-dropdown-button-download-csv" class="submit-dropdown-content-button disabled" type="button" value="download (csv)"></input>
|
46
47
|
</div>
|
47
48
|
</div>
|
48
49
|
</div>
|
data/client/resources/sqlui.js
CHANGED
@@ -3359,23 +3359,24 @@
|
|
3359
3359
|
*/
|
3360
3360
|
minPointSize = -1) {
|
3361
3361
|
let cursor = new SpanCursor(sets, null, minPointSize).goto(from), pos = from;
|
3362
|
-
let
|
3362
|
+
let openRanges = cursor.openStart;
|
3363
3363
|
for (;;) {
|
3364
3364
|
let curTo = Math.min(cursor.to, to);
|
3365
3365
|
if (cursor.point) {
|
3366
|
-
|
3367
|
-
|
3366
|
+
let active = cursor.activeForPoint(cursor.to);
|
3367
|
+
let openCount = cursor.pointFrom < from ? active.length + 1 : Math.min(active.length, openRanges);
|
3368
|
+
iterator.point(pos, curTo, cursor.point, active, openCount, cursor.pointRank);
|
3369
|
+
openRanges = Math.min(cursor.openEnd(curTo), active.length);
|
3368
3370
|
}
|
3369
3371
|
else if (curTo > pos) {
|
3370
|
-
iterator.span(pos, curTo, cursor.active,
|
3371
|
-
|
3372
|
+
iterator.span(pos, curTo, cursor.active, openRanges);
|
3373
|
+
openRanges = cursor.openEnd(curTo);
|
3372
3374
|
}
|
3373
3375
|
if (cursor.to > to)
|
3374
|
-
|
3376
|
+
return openRanges + (cursor.point && cursor.to > to ? 1 : 0);
|
3375
3377
|
pos = cursor.to;
|
3376
3378
|
cursor.next();
|
3377
3379
|
}
|
3378
|
-
return open;
|
3379
3380
|
}
|
3380
3381
|
/**
|
3381
3382
|
Create a range set for the given range or array of ranges. By
|
@@ -3679,6 +3680,8 @@
|
|
3679
3680
|
this.pointRank = 0;
|
3680
3681
|
this.to = -1000000000 /* C.Far */;
|
3681
3682
|
this.endSide = 0;
|
3683
|
+
// The amount of open active ranges at the start of the iterator.
|
3684
|
+
// Not including points.
|
3682
3685
|
this.openStart = -1;
|
3683
3686
|
this.cursor = HeapCursor.from(sets, skip, minPoint);
|
3684
3687
|
}
|
@@ -3719,7 +3722,7 @@
|
|
3719
3722
|
next() {
|
3720
3723
|
let from = this.to, wasPoint = this.point;
|
3721
3724
|
this.point = null;
|
3722
|
-
let trackOpen = this.openStart < 0 ? [] : null
|
3725
|
+
let trackOpen = this.openStart < 0 ? [] : null;
|
3723
3726
|
for (;;) {
|
3724
3727
|
let a = this.minActive;
|
3725
3728
|
if (a > -1 && (this.activeTo[a] - this.cursor.from || this.active[a].endSide - this.cursor.startSide) < 0) {
|
@@ -3745,8 +3748,6 @@
|
|
3745
3748
|
let nextVal = this.cursor.value;
|
3746
3749
|
if (!nextVal.point) { // Opening a range
|
3747
3750
|
this.addActive(trackOpen);
|
3748
|
-
if (this.cursor.from < from && this.cursor.to > from)
|
3749
|
-
trackExtra++;
|
3750
3751
|
this.cursor.next();
|
3751
3752
|
}
|
3752
3753
|
else if (wasPoint && this.cursor.to == this.to && this.cursor.from < this.cursor.to) {
|
@@ -3759,8 +3760,6 @@
|
|
3759
3760
|
this.pointRank = this.cursor.rank;
|
3760
3761
|
this.to = this.cursor.to;
|
3761
3762
|
this.endSide = nextVal.endSide;
|
3762
|
-
if (this.cursor.from < from)
|
3763
|
-
trackExtra = 1;
|
3764
3763
|
this.cursor.next();
|
3765
3764
|
this.forward(this.to, this.endSide);
|
3766
3765
|
break;
|
@@ -3768,10 +3767,9 @@
|
|
3768
3767
|
}
|
3769
3768
|
}
|
3770
3769
|
if (trackOpen) {
|
3771
|
-
|
3772
|
-
|
3773
|
-
openStart++;
|
3774
|
-
this.openStart = openStart + trackExtra;
|
3770
|
+
this.openStart = 0;
|
3771
|
+
for (let i = trackOpen.length - 1; i >= 0 && trackOpen[i] < from; i--)
|
3772
|
+
this.openStart++;
|
3775
3773
|
}
|
3776
3774
|
}
|
3777
3775
|
activeForPoint(to) {
|
@@ -5826,7 +5824,7 @@
|
|
5826
5824
|
}
|
5827
5825
|
}
|
5828
5826
|
let take = Math.min(this.text.length - this.textOff, length, 512 /* T.Chunk */);
|
5829
|
-
this.flushBuffer(active.slice(
|
5827
|
+
this.flushBuffer(active.slice(active.length - openStart));
|
5830
5828
|
this.getLine().append(wrapMarks(new TextView(this.text.slice(this.textOff, this.textOff + take)), active), openStart);
|
5831
5829
|
this.atCursorPos = true;
|
5832
5830
|
this.textOff += take;
|
@@ -20367,6 +20365,8 @@
|
|
20367
20365
|
if (tr.selection || active.some(a => a.hasResult() && tr.changes.touchesRange(a.from, a.to)) ||
|
20368
20366
|
!sameResults(active, this.active))
|
20369
20367
|
open = CompletionDialog.build(active, state, this.id, this.open, conf);
|
20368
|
+
else if (open && open.disabled && !active.some(a => a.state == 1 /* State.Pending */))
|
20369
|
+
open = null;
|
20370
20370
|
else if (open && tr.docChanged)
|
20371
20371
|
open = open.map(tr.changes);
|
20372
20372
|
if (!open && active.every(a => a.state != 1 /* State.Pending */) && active.some(a => a.hasResult()))
|
@@ -21292,7 +21292,7 @@
|
|
21292
21292
|
- F8: [`nextDiagnostic`](https://codemirror.net/6/docs/ref/#lint.nextDiagnostic)
|
21293
21293
|
*/
|
21294
21294
|
const lintKeymap = [
|
21295
|
-
{ key: "Mod-Shift-m", run: openLintPanel },
|
21295
|
+
{ key: "Mod-Shift-m", run: openLintPanel, preventDefault: true },
|
21296
21296
|
{ key: "F8", run: nextDiagnostic }
|
21297
21297
|
];
|
21298
21298
|
const lintPlugin = /*@__PURE__*/ViewPlugin.fromClass(class {
|
@@ -21916,10 +21916,10 @@
|
|
21916
21916
|
canShift(term) {
|
21917
21917
|
for (let sim = new SimulatedStack(this);;) {
|
21918
21918
|
let action = this.p.parser.stateSlot(sim.state, 4 /* DefaultReduce */) || this.p.parser.hasAction(sim.state, term);
|
21919
|
-
if ((action & 65536 /* ReduceFlag */) == 0)
|
21920
|
-
return true;
|
21921
21919
|
if (action == 0)
|
21922
21920
|
return false;
|
21921
|
+
if ((action & 65536 /* ReduceFlag */) == 0)
|
21922
|
+
return true;
|
21923
21923
|
sim.reduce(action);
|
21924
21924
|
}
|
21925
21925
|
}
|
@@ -23271,7 +23271,7 @@
|
|
23271
23271
|
LineComment = 1,
|
23272
23272
|
BlockComment = 2,
|
23273
23273
|
String$1 = 3,
|
23274
|
-
Number = 4,
|
23274
|
+
Number$1 = 4,
|
23275
23275
|
Bool = 5,
|
23276
23276
|
Null = 6,
|
23277
23277
|
ParenL = 7,
|
@@ -23540,18 +23540,18 @@
|
|
23540
23540
|
input.advance();
|
23541
23541
|
if (quoted && input.next == 39 /* Ch.SingleQuote */)
|
23542
23542
|
input.advance();
|
23543
|
-
input.acceptToken(Number);
|
23543
|
+
input.acceptToken(Number$1);
|
23544
23544
|
}
|
23545
23545
|
else if (next == 46 /* Ch.Dot */ && input.next >= 48 /* Ch._0 */ && input.next <= 57 /* Ch._9 */) {
|
23546
23546
|
readNumber(input, true);
|
23547
|
-
input.acceptToken(Number);
|
23547
|
+
input.acceptToken(Number$1);
|
23548
23548
|
}
|
23549
23549
|
else if (next == 46 /* Ch.Dot */) {
|
23550
23550
|
input.acceptToken(Dot);
|
23551
23551
|
}
|
23552
23552
|
else if (next >= 48 /* Ch._0 */ && next <= 57 /* Ch._9 */) {
|
23553
23553
|
readNumber(input, false);
|
23554
|
-
input.acceptToken(Number);
|
23554
|
+
input.acceptToken(Number$1);
|
23555
23555
|
}
|
23556
23556
|
else if (inString(next, d.operatorChars)) {
|
23557
23557
|
while (inString(input.next, d.operatorChars))
|
@@ -23862,6 +23862,36 @@
|
|
23862
23862
|
|
23863
23863
|
/* global google */
|
23864
23864
|
|
23865
|
+
function unquoteSqlId (identifier) {
|
23866
|
+
const match = identifier.match(/^`(.*)`$/);
|
23867
|
+
return match ? match[1] : identifier
|
23868
|
+
}
|
23869
|
+
|
23870
|
+
function base64Encode (str) {
|
23871
|
+
// https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
|
23872
|
+
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
|
23873
|
+
function toSolidBytes (match, p1) {
|
23874
|
+
return String.fromCharCode(Number(`0x${p1}`))
|
23875
|
+
}))
|
23876
|
+
}
|
23877
|
+
|
23878
|
+
function getSqlFromUrl (url) {
|
23879
|
+
const params = url.searchParams;
|
23880
|
+
if (params.has('file') && params.has('sql')) {
|
23881
|
+
// TODO: show an error.
|
23882
|
+
throw new Error('You can only specify a file or sql param, not both.')
|
23883
|
+
}
|
23884
|
+
if (params.has('sql')) {
|
23885
|
+
return params.get('sql')
|
23886
|
+
} else if (params.has('file')) {
|
23887
|
+
const file = params.get('file');
|
23888
|
+
const fileDetails = window.metadata.saved[file];
|
23889
|
+
if (!fileDetails) throw new Error(`no such file: ${file}`)
|
23890
|
+
return fileDetails.contents
|
23891
|
+
}
|
23892
|
+
throw new Error('You must specify a file or sql param')
|
23893
|
+
}
|
23894
|
+
|
23865
23895
|
function init (parent, onSubmit, onShiftSubmit) {
|
23866
23896
|
addClickListener(document.getElementById('query-tab-button'), (event) => selectTab(event, 'query'));
|
23867
23897
|
addClickListener(document.getElementById('saved-tab-button'), (event) => selectTab(event, 'saved'));
|
@@ -23902,9 +23932,7 @@
|
|
23902
23932
|
|
23903
23933
|
const copyListenerFactory = (delimiter) => {
|
23904
23934
|
return () => {
|
23905
|
-
if (
|
23906
|
-
!window.sqlFetch?.result
|
23907
|
-
) {
|
23935
|
+
if (!window.sqlFetch?.result) {
|
23908
23936
|
return
|
23909
23937
|
}
|
23910
23938
|
const type = 'text/plain';
|
@@ -23930,6 +23958,21 @@
|
|
23930
23958
|
};
|
23931
23959
|
addClickListener(document.getElementById('submit-dropdown-button-copy-csv'), copyListenerFactory(','));
|
23932
23960
|
addClickListener(document.getElementById('submit-dropdown-button-copy-tsv'), copyListenerFactory('\t'));
|
23961
|
+
addClickListener(document.getElementById('submit-dropdown-button-download-csv'), () => {
|
23962
|
+
if (!window.sqlFetch?.result) return
|
23963
|
+
|
23964
|
+
const url = new URL(window.location);
|
23965
|
+
url.searchParams.set('sql', base64Encode(getSqlFromUrl(url)));
|
23966
|
+
url.searchParams.delete('file');
|
23967
|
+
setActionInUrl(url, 'download_csv');
|
23968
|
+
|
23969
|
+
const link = document.createElement('a');
|
23970
|
+
link.setAttribute('download', 'result.csv');
|
23971
|
+
link.setAttribute('href', url.href);
|
23972
|
+
link.click();
|
23973
|
+
|
23974
|
+
focus(getSelection());
|
23975
|
+
});
|
23933
23976
|
|
23934
23977
|
document.addEventListener('click', function (event) {
|
23935
23978
|
if (event.target !== dropdownButton) {
|
@@ -23956,16 +23999,17 @@
|
|
23956
23999
|
schemas.forEach(([schemaName, schema]) => {
|
23957
24000
|
Object.entries(schema.tables).forEach(([tableName, table]) => {
|
23958
24001
|
const qualifiedTableName = schemas.length === 1 ? tableName : `${schemaName}.${tableName}`;
|
24002
|
+
const quotedQualifiedTableName = schemas.length === 1 ? `\`${tableName}\`` : `\`${schemaName}\`.\`${tableName}\``;
|
23959
24003
|
const columns = Object.keys(table.columns);
|
23960
24004
|
editorSchema[qualifiedTableName] = columns;
|
23961
|
-
const alias = window.metadata.table_aliases[
|
24005
|
+
const alias = window.metadata.table_aliases[qualifiedTableName];
|
23962
24006
|
if (alias) {
|
23963
24007
|
editorSchema[alias] = columns;
|
23964
24008
|
tables.push({
|
23965
24009
|
label: qualifiedTableName,
|
23966
24010
|
detail: alias,
|
23967
24011
|
alias_type: 'with',
|
23968
|
-
quoted:
|
24012
|
+
quoted: `${quotedQualifiedTableName} \`${alias}\``,
|
23969
24013
|
unquoted: `${qualifiedTableName} ${alias}`
|
23970
24014
|
});
|
23971
24015
|
tables.push({
|
@@ -24018,40 +24062,81 @@
|
|
24018
24062
|
schema: editorSchema,
|
24019
24063
|
tables
|
24020
24064
|
};
|
24021
|
-
const
|
24065
|
+
const originalSchemaCompletionSource = schemaCompletionSource(sqlConfig);
|
24066
|
+
const originalKeywordCompletionSource = keywordCompletionSource(MySQL, true);
|
24067
|
+
const keywordCompletions = [];
|
24068
|
+
window.metadata.joins.forEach((join) => {
|
24069
|
+
['JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'CROSS JOIN'].forEach((type) => {
|
24070
|
+
keywordCompletions.push({ label: `${type} ${join.label}`, apply: `${type} ${join.apply}`, type: 'keyword' });
|
24071
|
+
});
|
24072
|
+
});
|
24073
|
+
let combinedKeywordCompletionSource;
|
24074
|
+
if (keywordCompletions.length > 0) {
|
24075
|
+
const customKeywordCompletionSource = ifNotIn(['QuotedIdentifier', 'SpecialVar', 'String', 'LineComment', 'BlockComment', '.'], completeFromList(keywordCompletions));
|
24076
|
+
combinedKeywordCompletionSource = function (context) {
|
24077
|
+
const original = originalKeywordCompletionSource(context);
|
24078
|
+
const custom = customKeywordCompletionSource(context);
|
24079
|
+
if (original?.options && custom?.options) {
|
24080
|
+
original.options = original.options.concat(custom.options);
|
24081
|
+
}
|
24082
|
+
return original
|
24083
|
+
};
|
24084
|
+
} else {
|
24085
|
+
combinedKeywordCompletionSource = originalKeywordCompletionSource;
|
24086
|
+
}
|
24022
24087
|
const sqlExtension = new LanguageSupport(
|
24023
24088
|
MySQL.language,
|
24024
24089
|
[
|
24025
24090
|
MySQL.language.data.of({
|
24026
24091
|
autocomplete: (context) => {
|
24027
|
-
const result =
|
24092
|
+
const result = originalSchemaCompletionSource(context);
|
24028
24093
|
if (!hasTableAliases || !result?.options) return result
|
24029
24094
|
|
24030
24095
|
const tree = syntaxTree(context.state);
|
24031
24096
|
let node = tree.resolveInner(context.pos, -1);
|
24097
|
+
if (!node) return result
|
24032
24098
|
|
24033
24099
|
// We are trying to identify the case where we are autocompleting a table name after "from" or "join"
|
24100
|
+
|
24034
24101
|
// TODO: we don't handle the case where a user typed "select table.foo from". In that case we probably
|
24035
24102
|
// shouldn't autocomplete the alias. Though, if the user typed "select table.foo, t.bar", we won't know
|
24036
|
-
// what to do.
|
24103
|
+
// what to do. Maybe it is ok to force users to simply delete the alias after autocompleting.
|
24104
|
+
|
24037
24105
|
// TODO: if table aliases aren't enabled, we don't need to override autocomplete.
|
24038
24106
|
|
24039
|
-
|
24107
|
+
let foundSchema;
|
24108
|
+
if (node.name === 'Statement') {
|
24040
24109
|
// The node can be a Statement if the cursor is at the end of "from " and there is a complete
|
24041
24110
|
// statement in the editor (semicolon present). In that case we want to find the node just before the
|
24042
24111
|
// current position so that we can check whether it is "from" or "join".
|
24043
24112
|
node = node.childBefore(context.pos);
|
24044
|
-
} else if (node
|
24113
|
+
} else if (node.name === 'Script') {
|
24045
24114
|
// It seems the node can sometimes be a Script if the cursor is at the end of the last statement in the
|
24046
24115
|
// editor and the statement doesn't end in a semicolon. In that case we can find the last statement in the
|
24047
24116
|
// Script so that we can check whether it is "from" or "join".
|
24048
24117
|
node = node.lastChild?.childBefore(context.pos);
|
24049
|
-
} else if (['Identifier', 'QuotedIdentifier', 'Keyword'].includes(node
|
24118
|
+
} else if (['Identifier', 'QuotedIdentifier', 'Keyword', '.'].includes(node.name)) {
|
24050
24119
|
// If the node is an Identifier, we might be in the middle of typing the table name. If the node is a
|
24051
24120
|
// Keyword but isn't "from" or "join", we might be in the middle of typing a table name that is similar
|
24052
24121
|
// to a Keyword, for instance "orders" or "selections" or "fromages". In these cases, look for the previous
|
24053
|
-
// sibling so that we can check whether it is "from" or "join".
|
24054
|
-
|
24122
|
+
// sibling so that we can check whether it is "from" or "join". If we found a '.' or if the previous
|
24123
|
+
// sibling is a '.', we might be in the middle of typing something like "schema.table" or
|
24124
|
+
// "`schema`.table" or "`schema`.`table`". In these cases we need to record the schema used so that we
|
24125
|
+
// can autocomplete table names with aliases.
|
24126
|
+
if (node.name !== '.') {
|
24127
|
+
node = node.prevSibling;
|
24128
|
+
}
|
24129
|
+
|
24130
|
+
if (node?.name === '.') {
|
24131
|
+
node = node.prevSibling;
|
24132
|
+
if (['Identifier', 'QuotedIdentifier'].includes(node?.name)) {
|
24133
|
+
foundSchema = unquoteSqlId(context.state.doc.sliceString(node.from, node.to));
|
24134
|
+
node = node.parent;
|
24135
|
+
if (node?.name === 'CompositeIdentifier') {
|
24136
|
+
node = node.prevSibling;
|
24137
|
+
}
|
24138
|
+
}
|
24139
|
+
}
|
24055
24140
|
}
|
24056
24141
|
|
24057
24142
|
const nodeText = node ? context.state.doc.sliceString(node.from, node.to).toLowerCase() : null;
|
@@ -24076,13 +24161,21 @@
|
|
24076
24161
|
option.apply = option.unquoted;
|
24077
24162
|
}
|
24078
24163
|
}
|
24164
|
+
if (foundSchema) {
|
24165
|
+
const unquotedLabel = unquoteSqlId(option.label);
|
24166
|
+
const quoted = unquotedLabel !== option.label;
|
24167
|
+
const alias = window.metadata.table_aliases[`${foundSchema}.${unquotedLabel}`];
|
24168
|
+
if (alias) {
|
24169
|
+
option = { label: quoted ? `\`${unquotedLabel}\` \`${alias}\`` : `${option.label} ${alias}` };
|
24170
|
+
}
|
24171
|
+
}
|
24079
24172
|
return option
|
24080
24173
|
});
|
24081
24174
|
return result
|
24082
24175
|
}
|
24083
24176
|
}),
|
24084
24177
|
MySQL.language.data.of({
|
24085
|
-
autocomplete:
|
24178
|
+
autocomplete: combinedKeywordCompletionSource
|
24086
24179
|
})
|
24087
24180
|
]
|
24088
24181
|
);
|
@@ -24168,8 +24261,8 @@
|
|
24168
24261
|
});
|
24169
24262
|
}
|
24170
24263
|
|
24171
|
-
function
|
24172
|
-
url.pathname = url.pathname.replace(/\/[^/]+$/, `/${
|
24264
|
+
function setActionInUrl (url, action) {
|
24265
|
+
url.pathname = url.pathname.replace(/\/[^/]+$/, `/${action}`);
|
24173
24266
|
}
|
24174
24267
|
|
24175
24268
|
function getTabFromUrl (url) {
|
@@ -24183,19 +24276,19 @@
|
|
24183
24276
|
|
24184
24277
|
function updateTabs () {
|
24185
24278
|
const url = new URL(window.location);
|
24186
|
-
|
24279
|
+
setActionInUrl(url, 'graph');
|
24187
24280
|
document.getElementById('graph-tab-button').href = url.pathname + url.search;
|
24188
|
-
|
24281
|
+
setActionInUrl(url, 'saved');
|
24189
24282
|
document.getElementById('saved-tab-button').href = url.pathname + url.search;
|
24190
|
-
|
24283
|
+
setActionInUrl(url, 'structure');
|
24191
24284
|
document.getElementById('structure-tab-button').href = url.pathname + url.search;
|
24192
|
-
|
24285
|
+
setActionInUrl(url, 'query');
|
24193
24286
|
document.getElementById('query-tab-button').href = url.pathname + url.search;
|
24194
24287
|
}
|
24195
24288
|
|
24196
24289
|
function selectTab (event, tab) {
|
24197
24290
|
const url = new URL(window.location);
|
24198
|
-
|
24291
|
+
setActionInUrl(url, tab);
|
24199
24292
|
route(event.target, event, url, true);
|
24200
24293
|
}
|
24201
24294
|
|
@@ -24404,6 +24497,7 @@
|
|
24404
24497
|
document.getElementById('graph-status').style.display = 'flex';
|
24405
24498
|
document.getElementById('fetch-sql-box').style.display = 'none';
|
24406
24499
|
document.getElementById('cancel-button').style.display = 'none';
|
24500
|
+
updateDownloadButtons(window?.sqlFetch);
|
24407
24501
|
maybeFetchResult(internal);
|
24408
24502
|
|
24409
24503
|
focus(getSelection());
|
@@ -24438,7 +24532,7 @@
|
|
24438
24532
|
setSavedStatus(`${numFiles} file${numFiles === 1 ? '' : 's'}`);
|
24439
24533
|
Object.values(saved).forEach(file => {
|
24440
24534
|
const viewUrl = new URL(window.location.origin + window.location.pathname);
|
24441
|
-
|
24535
|
+
setActionInUrl(viewUrl, 'query');
|
24442
24536
|
viewUrl.searchParams.set('file', file.filename);
|
24443
24537
|
|
24444
24538
|
const viewLinkElement = document.createElement('a');
|
@@ -24451,7 +24545,7 @@
|
|
24451
24545
|
});
|
24452
24546
|
|
24453
24547
|
const runUrl = new URL(window.location.origin + window.location.pathname);
|
24454
|
-
|
24548
|
+
setActionInUrl(runUrl, 'query');
|
24455
24549
|
runUrl.searchParams.set('file', file.filename);
|
24456
24550
|
runUrl.searchParams.set('run', 'true');
|
24457
24551
|
|
@@ -24552,6 +24646,7 @@
|
|
24552
24646
|
clearGraphStatus();
|
24553
24647
|
clearResultBox();
|
24554
24648
|
clearResultStatus();
|
24649
|
+
disableDownloadButtons();
|
24555
24650
|
}
|
24556
24651
|
|
24557
24652
|
function clearResultStatus () {
|
@@ -24821,7 +24916,28 @@
|
|
24821
24916
|
});
|
24822
24917
|
}
|
24823
24918
|
|
24919
|
+
function disableDownloadButtons () {
|
24920
|
+
document.getElementById('submit-dropdown-button-download-csv').classList.add('disabled');
|
24921
|
+
document.getElementById('submit-dropdown-button-copy-csv').classList.add('disabled');
|
24922
|
+
document.getElementById('submit-dropdown-button-copy-tsv').classList.add('disabled');
|
24923
|
+
}
|
24924
|
+
|
24925
|
+
function enableDownloadButtons () {
|
24926
|
+
document.getElementById('submit-dropdown-button-download-csv').classList.remove('disabled');
|
24927
|
+
document.getElementById('submit-dropdown-button-copy-csv').classList.remove('disabled');
|
24928
|
+
document.getElementById('submit-dropdown-button-copy-tsv').classList.remove('disabled');
|
24929
|
+
}
|
24930
|
+
|
24931
|
+
function updateDownloadButtons (fetch) {
|
24932
|
+
if (fetch?.state === 'success') {
|
24933
|
+
enableDownloadButtons();
|
24934
|
+
} else {
|
24935
|
+
disableDownloadButtons();
|
24936
|
+
}
|
24937
|
+
}
|
24938
|
+
|
24824
24939
|
function displaySqlFetch (fetch) {
|
24940
|
+
updateDownloadButtons(fetch);
|
24825
24941
|
if (window.tab === 'query') {
|
24826
24942
|
displaySqlFetchInResultTab(fetch);
|
24827
24943
|
} else if (window.tab === 'graph') {
|
@@ -24950,7 +25066,7 @@
|
|
24950
25066
|
if (result.total_rows === 1) {
|
24951
25067
|
statusElement.innerText = `${result.total_rows} row (${elapsed}s)`;
|
24952
25068
|
} else {
|
24953
|
-
statusElement.innerText = `${result.total_rows} rows (${elapsed}s)`;
|
25069
|
+
statusElement.innerText = `${result.total_rows.toLocaleString()} rows (${elapsed}s)`;
|
24954
25070
|
}
|
24955
25071
|
|
24956
25072
|
if (result.total_rows > result.rows.length) {
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sqlui
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.42
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nick Dower
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-11-
|
11
|
+
date: 2022-11-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: mysql2
|