sqlui 0.1.47 → 0.1.49

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: 01c105468ea4c5f463ada429231bd41a3a65752d8fa95f8befa2121a47765fbe
4
- data.tar.gz: 6e0df11aa97b0e8d0d685e6b109a297c8ddba78959f109db26f7b159aa4cfa0c
3
+ metadata.gz: ae60c3a8d2bb051b87f0e10ed67b3c5e5c6360ecd298b5176a17a7a5f66796d8
4
+ data.tar.gz: 4ee88fae0167dc7dd8958384d7b2492df3944368238453d729244d91232ebf35
5
5
  SHA512:
6
- metadata.gz: 6c1fd32e0feb8817bac5e120a15e71adf48e9ccbe5183e0ba729dc29c1a2bb76ffa18bd7c63559dd49f66f0eb8a3669213988701da8c3f4d4566919985c80c13
7
- data.tar.gz: 022b592a3ed56c7e06b8222566cfe7d096ed1b1789d687afe4bf945e625a390269dffd8a701fa4647377e85399b40e345147ea637113a2489e31fbd2630e409a
6
+ metadata.gz: b809f370ad688d9b54d50ff1ccf4dd703dea5af4344f61577220da2b3f4d7536265d8b8e03613158b81fdec4d86ca10dde35f89623cb114fde2b345a5df0108b
7
+ data.tar.gz: 70324412047c434e03c39e3676cd616da4f6a29c0c7767805fe4073d15e59dd1f28d994253921e18457c469154661081a4570cfec63100cfb9dd2d3ae1aec242
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.47
1
+ 0.1.49
data/app/args.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  class Args
5
5
  def self.fetch_non_empty_string(hash, key)
6
6
  value = fetch_non_nil(hash, key, String)
7
- raise ArgumentError, "required parameter #{key} empty" if value.strip.empty?
7
+ raise ArgumentError, "parameter #{key} empty" if value.strip.empty?
8
8
 
9
9
  value
10
10
  end
@@ -15,7 +15,7 @@ class Args
15
15
 
16
16
  def self.fetch_non_empty_hash(hash, key)
17
17
  value = fetch_non_nil(hash, key, Hash)
18
- raise ArgumentError, "required parameter #{key} empty" if value.empty?
18
+ raise ArgumentError, "parameter #{key} empty" if value.empty?
19
19
 
20
20
  value
21
21
  end
@@ -29,9 +29,9 @@ class Args
29
29
  end
30
30
 
31
31
  def self.fetch_non_nil(hash, key, *classes)
32
- raise ArgumentError, "required parameter #{key} missing" unless hash.key?(key)
32
+ raise ArgumentError, "parameter #{key} missing" unless hash.key?(key)
33
33
 
34
- raise ArgumentError, "required parameter #{key} null" if hash[key].nil?
34
+ raise ArgumentError, "parameter #{key} null" if hash[key].nil?
35
35
 
36
36
  fetch_optional(hash, key, *classes)
37
37
  end
@@ -40,12 +40,12 @@ class Args
40
40
  value = hash[key]
41
41
  if value && classes.size.positive? && !classes.find { |clazz| value.is_a?(clazz) }
42
42
  if classes.size != 1
43
- raise ArgumentError, "required parameter #{key} not #{classes.map(&:to_s).map(&:downcase).join(' or ')}"
43
+ raise ArgumentError, "parameter #{key} not #{classes.map(&:to_s).map(&:downcase).join(' or ')}"
44
44
  end
45
45
 
46
46
  class_name = classes[0].to_s.downcase
47
47
  class_name = %w[a e i o u].include?(class_name[0]) ? "an #{class_name}" : "a #{class_name}"
48
- raise ArgumentError, "required parameter #{key} not #{class_name}"
48
+ raise ArgumentError, "parameter #{key} not #{class_name}"
49
49
  end
50
50
 
51
51
  value
@@ -8,7 +8,7 @@ require_relative 'args'
8
8
 
9
9
  # Config for a single database.
10
10
  class DatabaseConfig
11
- attr_reader :display_name, :description, :url_path, :joins, :saved_path, :tables, :client_params
11
+ attr_reader :display_name, :description, :url_path, :joins, :saved_path, :tables, :columns, :client_params
12
12
 
13
13
  def initialize(hash)
14
14
  @display_name = Args.fetch_non_empty_string(hash, :display_name).strip
@@ -18,8 +18,10 @@ class DatabaseConfig
18
18
  raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
19
19
 
20
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|
21
+
22
+ # Make joins an array. It is only a map to allow for YAML extension.
23
+ @joins = (Args.fetch_optional_hash(hash, :joins) || {}).values
24
+ @joins.each do |join|
23
25
  next if join.is_a?(Hash) &&
24
26
  join.keys.size == 2 &&
25
27
  join[:label].is_a?(String) && !join[:label].strip.empty? &&
@@ -27,8 +29,9 @@ class DatabaseConfig
27
29
 
28
30
  raise ArgumentError, "invalid join #{join.to_json}"
29
31
  end
32
+
30
33
  @tables = Args.fetch_optional_hash(hash, :tables) || {}
31
- @tables = @tables.each do |table, table_config|
34
+ @tables.each do |table, table_config|
32
35
  unless table_config.is_a?(Hash)
33
36
  raise ArgumentError, "invalid table config for #{table} (#{table_config}), expected hash"
34
37
  end
@@ -43,6 +46,33 @@ class DatabaseConfig
43
46
  raise ArgumentError, "invalid table boost for #{table} (#{table_boost}), expected int"
44
47
  end
45
48
  end
49
+
50
+ @columns = Args.fetch_optional_hash(hash, :columns) || {}
51
+ @columns.each do |column, column_config|
52
+ unless column_config.is_a?(Hash)
53
+ raise ArgumentError, "invalid column config for #{column} (#{column_config}), expected hash"
54
+ end
55
+
56
+ links = Args.fetch_optional_hash(column_config, :links) || {}
57
+ links.each_value do |link_config|
58
+ unless link_config.is_a?(Hash)
59
+ raise ArgumentError, "invalid link config for #{column} (#{link_config}), expected hash"
60
+ end
61
+
62
+ unless link_config[:short_name].is_a?(String)
63
+ raise ArgumentError,
64
+ "invalid link short_name for #{column} link (#{link_config[:short_name]}), expected string"
65
+ end
66
+ unless link_config[:long_name].is_a?(String)
67
+ raise ArgumentError, "invalid link long_name for #{column} link (#{link_config[:long_name]}), expected string"
68
+ end
69
+ unless link_config[:template].is_a?(String)
70
+ raise ArgumentError, "invalid link template for #{column} link (#{link_config[:template]}), expected string"
71
+ end
72
+ end
73
+ # Make links an array. It is only a map to allow for YAML extension
74
+ column_config[:links] = links.values
75
+ end
46
76
  aliases = @tables.map { |_table, table_config| table_config[:alias] }.compact
47
77
  if aliases.to_set.size < aliases.size
48
78
  duplicate_aliases = aliases.reject { |a| aliases.count(a) == 1 }.to_set
data/app/server.rb CHANGED
@@ -39,6 +39,10 @@ class Server < Sinatra::Base
39
39
  redirect config.list_url_path, 301
40
40
  end
41
41
 
42
+ get '/favicon.svg' do
43
+ send_file File.join(resources_dir, 'favicon.svg')
44
+ end
45
+
42
46
  get "#{config.list_url_path}/?" do
43
47
  erb :databases, locals: { config: config }
44
48
  end
@@ -69,6 +73,7 @@ class Server < Sinatra::Base
69
73
  list_url_path: config.list_url_path,
70
74
  schemas: DatabaseMetadata.lookup(client, database),
71
75
  tables: database.tables,
76
+ columns: database.columns,
72
77
  joins: database.joins,
73
78
  saved: Dir.glob("#{database.saved_path}/*.sql").to_h do |path|
74
79
  contents = File.read(path)
@@ -163,15 +168,18 @@ class Server < Sinatra::Base
163
168
  end
164
169
  end
165
170
 
166
- error do |e|
167
- status 500
168
- headers 'Content-Type' => 'application/json; charset=utf-8'
169
- message = e.message.lines.first&.strip || 'unexpected error'
170
- result = {
171
- error: message,
172
- stacktrace: e.backtrace.map { |b| b }.join("\n")
173
- }
174
- body result.to_json
171
+ error 400..510 do
172
+ exception = env['sinatra.error']
173
+ stacktrace = exception&.full_message(highlight: false)
174
+ if request.env['HTTP_ACCEPT'] == 'application/json'
175
+ headers 'Content-Type' => 'application/json; charset=utf-8'
176
+ message = exception&.message&.lines&.first&.strip || 'unexpected error'
177
+ json = { error: message, stacktrace: stacktrace }.compact.to_json
178
+ body json
179
+ else
180
+ message = "#{status} #{Rack::Utils::HTTP_STATUS_CODES[status]}"
181
+ erb :error, locals: { title: "SQLUI #{message}", message: message, stacktrace: stacktrace }
182
+ end
175
183
  end
176
184
 
177
185
  run!
data/app/sql_parser.rb CHANGED
@@ -1,19 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'strscan'
4
+
3
5
  # Used to parse strings containing one or more SQL statements.
4
6
  class SqlParser
5
7
  def self.find_statement_at_cursor(sql, cursor)
6
- return sql unless sql.include?(';')
7
-
8
- parts_with_ranges = []
9
- sql.scan(/[^;]*;[ \n]*/) { |part| parts_with_ranges << [part, 0, part.size] }
10
- parts_with_ranges.inject(0) do |pos, current|
11
- current[1] += pos
12
- current[2] += pos
13
- end
8
+ parts_with_ranges = parse(sql)
14
9
  part_with_range = parts_with_ranges.find do |current|
15
- cursor >= current[1] && cursor < current[2]
10
+ cursor >= current[1] && cursor <= current[2]
16
11
  end || parts_with_ranges[-1]
17
- part_with_range[0].strip
12
+ part_with_range[0]
13
+ end
14
+
15
+ def self.parse(sql)
16
+ scanner = StringScanner.new(sql)
17
+ statements = []
18
+ single_quoted = false
19
+ double_quoted = false
20
+ single_commented = false
21
+ multi_commented = false
22
+ escaped = false
23
+ current = ''
24
+ start = 0
25
+ until scanner.eos?
26
+ current ||= ''
27
+ char = scanner.getch
28
+ current += char
29
+
30
+ if (single_quoted || double_quoted) && !escaped && char == "'" && scanner.peek(1) == "'"
31
+ current += scanner.getch
32
+ elsif escaped
33
+ escaped = false
34
+ elsif !single_quoted && !double_quoted && !escaped && !single_commented && char == '/' && scanner.peek(1) == '*'
35
+ current += scanner.getch
36
+ multi_commented = true
37
+ elsif multi_commented && char == '*' && scanner.peek(1) == '/'
38
+ multi_commented = false
39
+ elsif multi_commented
40
+ next
41
+ elsif !single_quoted && !double_quoted && !escaped && !multi_commented &&
42
+ char == '-' && scanner.peek(1).match?(/-[ \n\t]/)
43
+ current += scanner.getch
44
+ single_commented = true
45
+ elsif !single_quoted && !double_quoted && !escaped && !multi_commented && char == '#'
46
+ single_commented = true
47
+ elsif single_commented && char == "\n"
48
+ single_commented = false
49
+ elsif single_commented
50
+ single_quoted = false if char == "\n"
51
+ elsif char == '\\'
52
+ escaped = true
53
+ elsif char == "'"
54
+ single_quoted = !single_quoted unless double_quoted
55
+ elsif char == '"'
56
+ double_quoted = !double_quoted unless single_quoted
57
+ elsif char == ';' && !single_quoted && !double_quoted
58
+ # Include trailing whitespace if it runs to end of line
59
+ current += scanner.scan(/[ \t]*(?=\n)/) || ''
60
+
61
+ # Include an optional trailing single line comma
62
+ current += scanner.scan(/[ \t]*--[ \t][^\n]*/) || ''
63
+ current += scanner.scan(/[ \t]*#[^\n]*/) || ''
64
+
65
+ # Include any following blank lines, careful to avoid the final newline in case it is the start of a new query
66
+ while (more = scanner.scan(/\n[ \t]*(?=\n)/))
67
+ current += more
68
+ end
69
+
70
+ # Include remaining whitespace if that's all that's left
71
+ if scanner.rest_size == scanner.match?(/[ \t\n]*/)
72
+ current += scanner.rest
73
+ scanner.terminate
74
+ end
75
+ statements << [current, start, scanner.pos]
76
+ start = scanner.pos
77
+ current = nil
78
+ end
79
+ end
80
+ statements << [current, start, scanner.pos] unless current.nil?
81
+ statements
18
82
  end
19
83
  end
@@ -0,0 +1,21 @@
1
+ <html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title><%= title %></title>
6
+ <link rel="icon" type="image/x-icon" href="/favicon.svg">
7
+ </head>
8
+ <body style="font-family: monospace; font-size: 16px;">
9
+
10
+ <% if defined?(message) && message %>
11
+ <p><%= message %></p>
12
+ <% end %>
13
+
14
+ <% if defined?(stacktrace) && stacktrace %>
15
+ <pre>
16
+ <%= stacktrace %>
17
+ </pre>
18
+ <% end %>
19
+
20
+ </body>
21
+ </html>
@@ -0,0 +1 @@
1
+ <svg version="1.1" viewBox="0.0 0.0 32.0 32.0" fill="none" stroke="none" stroke-linecap="square" stroke-miterlimit="10" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><clipPath id="p.0"><path d="m0 0l32.0 0l0 32.0l-32.0 0l0 -32.0z" clip-rule="nonzero"/></clipPath><g clip-path="url(#p.0)"><path fill="#000000" fill-opacity="0.0" d="m0 0l32.0 0l0 32.0l-32.0 0z" fill-rule="evenodd"/><path fill="#6d9eeb" d="m1.3070866 20.064245l0 0c0 1.6235943 6.5782413 2.9397774 14.692913 2.9397774c8.114672 0 14.692913 -1.3161831 14.692913 -2.9397774l0 8.073204c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774z" fill-rule="evenodd"/><path fill="#a7c4f3" d="m1.3070866 20.064245l0 0c0 -1.6235924 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161831 14.692913 2.9397755l0 0c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774z" fill-rule="evenodd"/><path fill="#000000" fill-opacity="0.0" d="m30.692913 20.064245l0 0c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774l0 0c0 -1.6235924 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161831 14.692913 2.9397755l0 8.073204c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774l0 -8.073204" fill-rule="evenodd"/><path stroke="#351c75" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="butt" d="m30.692913 20.064245l0 0c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774l0 0c0 -1.6235924 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161831 14.692913 2.9397755l0 8.073204c0 1.6235943 -6.5782413 2.9397774 -14.692913 2.9397774c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397774l0 -8.073204" fill-rule="evenodd"/><path fill="#93c47d" d="m1.3070866 12.012567l0 0c0 1.6235943 6.5782413 2.9397764 14.692913 2.9397764c8.114672 0 14.692913 -1.3161821 14.692913 -2.9397764l0 8.073205c0 1.6235924 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397755z" fill-rule="evenodd"/><path fill="#bedbb1" d="m1.3070866 12.012567l0 0c0 -1.6235933 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161821 14.692913 2.9397755l0 0c0 1.6235943 -6.5782413 2.9397764 -14.692913 2.9397764c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397764z" fill-rule="evenodd"/><path fill="#000000" fill-opacity="0.0" d="m30.692913 12.012567l0 0c0 1.6235943 -6.5782413 2.9397764 -14.692913 2.9397764c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397764l0 0c0 -1.6235933 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161821 14.692913 2.9397755l0 8.073205c0 1.6235924 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397755l0 -8.073205" fill-rule="evenodd"/><path stroke="#351c75" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="butt" d="m30.692913 12.012567l0 0c0 1.6235943 -6.5782413 2.9397764 -14.692913 2.9397764c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397764l0 0c0 -1.6235933 6.5782413 -2.9397755 14.692913 -2.9397755c8.114672 0 14.692913 1.3161821 14.692913 2.9397755l0 8.073205c0 1.6235924 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161831 -14.692913 -2.9397755l0 -8.073205" fill-rule="evenodd"/><path fill="#f4cccc" d="m1.3070866 3.862348l0 0c0 1.6235933 6.5782413 2.939776 14.692913 2.939776c8.114672 0 14.692913 -1.3161826 14.692913 -2.939776l0 8.073204c0 1.6235933 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397755z" fill-rule="evenodd"/><path fill="#f8e0e0" d="m1.3070866 3.862348l0 0c0 -1.6235933 6.5782413 -2.939776 14.692913 -2.939776c8.114672 0 14.692913 1.3161826 14.692913 2.939776l0 0c0 1.6235933 -6.5782413 2.939776 -14.692913 2.939776c-8.114672 0 -14.692913 -1.3161826 -14.692913 -2.939776z" fill-rule="evenodd"/><path fill="#000000" fill-opacity="0.0" d="m30.692913 3.862348l0 0c0 1.6235933 -6.5782413 2.939776 -14.692913 2.939776c-8.114672 0 -14.692913 -1.3161826 -14.692913 -2.939776l0 0c0 -1.6235933 6.5782413 -2.939776 14.692913 -2.939776c8.114672 0 14.692913 1.3161826 14.692913 2.939776l0 8.073204c0 1.6235933 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397755l0 -8.073204" fill-rule="evenodd"/><path stroke="#351c75" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="butt" d="m30.692913 3.862348l0 0c0 1.6235933 -6.5782413 2.939776 -14.692913 2.939776c-8.114672 0 -14.692913 -1.3161826 -14.692913 -2.939776l0 0c0 -1.6235933 6.5782413 -2.939776 14.692913 -2.939776c8.114672 0 14.692913 1.3161826 14.692913 2.939776l0 8.073204c0 1.6235933 -6.5782413 2.9397755 -14.692913 2.9397755c-8.114672 0 -14.692913 -1.3161821 -14.692913 -2.9397755l0 -8.073204" fill-rule="evenodd"/></g></svg>
@@ -158,7 +158,7 @@ p {
158
158
  border-left: 1px solid #888;
159
159
  border-right: 1px solid #888;
160
160
  border-bottom: 1px solid #888;
161
- z-index: 200;
161
+ z-index: 2;
162
162
  flex-direction: column;
163
163
  left: 0;
164
164
  right: 0;
@@ -246,6 +246,27 @@ p {
246
246
  flex-direction: column;
247
247
  }
248
248
 
249
+ #result-table tbody tr td abbr a {
250
+ color: #555;
251
+ cursor: pointer;
252
+ text-decoration: none;
253
+ margin: 0;
254
+ padding: 0 3px;
255
+ border: 1px dotted #555;
256
+ font-size: 12px;
257
+ display: inline-block;
258
+ }
259
+
260
+ #result-table tbody tr td abbr {
261
+ margin: 0 5px 0 0;
262
+ text-decoration: none;
263
+ position: relative;
264
+ }
265
+
266
+ #result-table tbody tr td abbr:last-child {
267
+ margin: 0;
268
+ }
269
+
249
270
  .fetch-sql-box {
250
271
  justify-content: center;
251
272
  align-items: center;
@@ -271,7 +292,7 @@ table td:last-child, table th:last-child {
271
292
  }
272
293
 
273
294
  td, th {
274
- padding: 5px 20px 5px 5px;
295
+ padding: 5px 10px;
275
296
  font-weight: normal;
276
297
  white-space: nowrap;
277
298
  max-width: 500px;
@@ -300,7 +321,7 @@ thead {
300
321
  position: -webkit-sticky;
301
322
  position: sticky;
302
323
  top: 0;
303
- z-index: 100;
324
+ z-index: 1;
304
325
  table-layout: fixed;
305
326
  }
306
327
 
@@ -2,6 +2,7 @@
2
2
  <head>
3
3
  <meta charset="utf-8">
4
4
  <title>SQLUI</title>
5
+ <link rel="icon" type="image/x-icon" href="/favicon.svg">
5
6
  <script src="sqlui.js"></script>
6
7
  <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
7
8
  <link rel="stylesheet" href="sqlui.css">
@@ -21666,6 +21666,57 @@
21666
21666
  }
21667
21667
  });
21668
21668
 
21669
+ function base64Encode (str) {
21670
+ // https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
21671
+ return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
21672
+ function toSolidBytes (match, p1) {
21673
+ return String.fromCharCode(Number(`0x${p1}`))
21674
+ }))
21675
+ }
21676
+
21677
+ function copyTextToClipboard (text) {
21678
+ const type = 'text/plain';
21679
+ const blob = new Blob([text], { type });
21680
+ navigator.clipboard.write([new window.ClipboardItem({ [type]: blob })]);
21681
+ }
21682
+
21683
+ function toCsv (columns, rows) {
21684
+ let text = columns.map((header) => {
21685
+ if (typeof header === 'string' && (header.includes('"') || header.includes(','))) {
21686
+ return `"${header.replaceAll('"', '""')}"`
21687
+ } else {
21688
+ return header
21689
+ }
21690
+ }).join(',') + '\n';
21691
+ text += rows.map((row) => {
21692
+ return row.map((cell) => {
21693
+ if (typeof cell === 'string' && (cell.includes('"') || cell.includes(','))) {
21694
+ return `"${cell.replaceAll('"', '""')}"`
21695
+ } else {
21696
+ return cell
21697
+ }
21698
+ }).join(',')
21699
+ }).join('\n');
21700
+
21701
+ return text
21702
+ }
21703
+
21704
+ function toTsv (columns, rows) {
21705
+ if (columns.find((cell) => cell === '\t')) {
21706
+ throw new Error('TSV input may not contain a tab character.')
21707
+ }
21708
+ let text = columns.join('\t') + '\n';
21709
+
21710
+ text += rows.map((row) => {
21711
+ if (row.find((cell) => cell === '\t')) {
21712
+ throw new Error('TSV input may not contain a tab character.')
21713
+ }
21714
+ return row.join('\t')
21715
+ }).join('\n');
21716
+
21717
+ return text
21718
+ }
21719
+
21669
21720
  /// A parse stack. These are used internally by the parser to track
21670
21721
  /// parsing progress. They also provide some properties and methods
21671
21722
  /// that external code such as a tokenizer can use to get information
@@ -23860,131 +23911,7 @@
23860
23911
  builtin: MySQLBuiltin
23861
23912
  });
23862
23913
 
23863
- /* global google */
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
-
23895
- function init (parent, onSubmit, onShiftSubmit) {
23896
- addClickListener(document.getElementById('query-tab-button'), (event) => selectTab(event, 'query'));
23897
- addClickListener(document.getElementById('saved-tab-button'), (event) => selectTab(event, 'saved'));
23898
- addClickListener(document.getElementById('structure-tab-button'), (event) => selectTab(event, 'structure'));
23899
- addClickListener(document.getElementById('graph-tab-button'), (event) => selectTab(event, 'graph'));
23900
- addClickListener(document.getElementById('cancel-button'), (event) => clearResult());
23901
-
23902
- const dropdownContent = document.getElementById('submit-dropdown-content');
23903
- const dropdownButton = document.getElementById('submit-dropdown-button');
23904
- addClickListener(dropdownButton, () => dropdownContent.classList.toggle('submit-dropdown-content-show'));
23905
-
23906
- const isMac = navigator.userAgent.includes('Mac');
23907
- const runCurrentLabel = `run selection (${isMac ? '⌘' : 'Ctrl'}-Enter)`;
23908
- const runAllLabel = `run all (${isMac ? '⌘' : 'Ctrl'}-Shift-Enter)`;
23909
-
23910
- const submitButtonCurrent = document.getElementById('submit-button-current');
23911
- submitButtonCurrent.value = runCurrentLabel;
23912
- addClickListener(submitButtonCurrent, (event) => submitCurrent(event.target, event));
23913
-
23914
- const submitButtonAll = document.getElementById('submit-button-all');
23915
- submitButtonAll.value = runAllLabel;
23916
- addClickListener(submitButtonAll, (event) => submitAll(event.target, event));
23917
-
23918
- const dropdownButtonCurrent = document.getElementById('submit-dropdown-button-current');
23919
- dropdownButtonCurrent.value = runCurrentLabel;
23920
- addClickListener(dropdownButtonCurrent, (event) => submitCurrent(event.target, event));
23921
-
23922
- const dropdownAllButton = document.getElementById('submit-dropdown-button-all');
23923
- dropdownAllButton.value = runAllLabel;
23924
- addClickListener(dropdownAllButton, (event) => submitAll(event.target, event));
23925
-
23926
- const dropdownToggleButton = document.getElementById('submit-dropdown-button-toggle');
23927
- addClickListener(dropdownToggleButton, () => {
23928
- submitButtonCurrent.classList.toggle('submit-button-show');
23929
- submitButtonAll.classList.toggle('submit-button-show');
23930
- focus(getSelection());
23931
- });
23932
-
23933
- const copyListenerFactory = (delimiter) => {
23934
- return () => {
23935
- if (!window.sqlFetch?.result) {
23936
- return
23937
- }
23938
- const type = 'text/plain';
23939
- let text = window.sqlFetch.result.columns.map((header) => {
23940
- if (typeof header === 'string' && (header.includes('"') || header.includes(delimiter))) {
23941
- return `"${header.replaceAll('"', '""')}"`
23942
- } else {
23943
- return header
23944
- }
23945
- }).join(delimiter) + '\n';
23946
- text += window.sqlFetch.result.rows.map((row) => {
23947
- return row.map((cell) => {
23948
- if (typeof cell === 'string' && (cell.includes('"') || cell.includes(delimiter))) {
23949
- return `"${cell.replaceAll('"', '""')}"`
23950
- } else {
23951
- return cell
23952
- }
23953
- }).join(delimiter)
23954
- }).join('\n');
23955
- const blob = new Blob([text], { type });
23956
- navigator.clipboard.write([new window.ClipboardItem({ [type]: blob })]);
23957
- }
23958
- };
23959
- addClickListener(document.getElementById('submit-dropdown-button-copy-csv'), copyListenerFactory(','));
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
- });
23976
-
23977
- document.addEventListener('click', function (event) {
23978
- if (event.target !== dropdownButton) {
23979
- dropdownContent.classList.remove('submit-dropdown-content-show');
23980
- }
23981
- });
23982
- dropdownContent.addEventListener('focusout', function (event) {
23983
- if (!dropdownContent.contains(event.relatedTarget)) {
23984
- dropdownContent.classList.remove('submit-dropdown-content-show');
23985
- }
23986
- });
23987
-
23914
+ function createEditor (parent, metadata, onSubmit, onShiftSubmit) {
23988
23915
  const fixedHeightEditor = EditorView.theme({
23989
23916
  '.cm-scroller': {
23990
23917
  height: '200px',
@@ -23992,7 +23919,7 @@
23992
23919
  resize: 'vertical'
23993
23920
  }
23994
23921
  });
23995
- const schemas = Object.entries(window.metadata.schemas);
23922
+ const schemas = Object.entries(metadata.schemas);
23996
23923
  const editorSchema = {};
23997
23924
  const tables = [];
23998
23925
  schemas.forEach(([schemaName, schema]) => {
@@ -24001,8 +23928,8 @@
24001
23928
  const quotedQualifiedTableName = schemas.length === 1 ? `\`${tableName}\`` : `\`${schemaName}\`.\`${tableName}\``;
24002
23929
  const columns = Object.keys(table.columns);
24003
23930
  editorSchema[qualifiedTableName] = columns;
24004
- const alias = window.metadata.tables[qualifiedTableName]?.alias;
24005
- const boost = window.metadata.tables[qualifiedTableName]?.boost;
23931
+ const alias = metadata.tables[qualifiedTableName]?.alias;
23932
+ const boost = metadata.tables[qualifiedTableName]?.boost;
24006
23933
  if (alias) {
24007
23934
  editorSchema[alias] = columns;
24008
23935
  tables.push({
@@ -24067,7 +23994,7 @@
24067
23994
  const originalSchemaCompletionSource = schemaCompletionSource(sqlConfig);
24068
23995
  const originalKeywordCompletionSource = keywordCompletionSource(MySQL, true);
24069
23996
  const keywordCompletions = [];
24070
- window.metadata.joins.forEach((join) => {
23997
+ metadata.joins.forEach((join) => {
24071
23998
  ['JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'CROSS JOIN'].forEach((type) => {
24072
23999
  keywordCompletions.push({ label: `${type} ${join.label}`, apply: `${type} ${join.apply}`, type: 'keyword' });
24073
24000
  });
@@ -24166,7 +24093,7 @@
24166
24093
  if (foundSchema) {
24167
24094
  const unquotedLabel = unquoteSqlId(option.label);
24168
24095
  const quoted = unquotedLabel !== option.label;
24169
- const tableConfig = window.metadata.tables[`${foundSchema}.${unquotedLabel}`];
24096
+ const tableConfig = metadata.tables[`${foundSchema}.${unquotedLabel}`];
24170
24097
  const alias = tableConfig?.alias;
24171
24098
  const boost = tableConfig?.boost || -1;
24172
24099
  const optionOverride = {
@@ -24190,7 +24117,7 @@
24190
24117
  })
24191
24118
  ]
24192
24119
  );
24193
- window.editorView = new EditorView({
24120
+ return new EditorView({
24194
24121
  state: EditorState.create({
24195
24122
  extensions: [
24196
24123
  lineNumbers(),
@@ -24217,7 +24144,152 @@
24217
24144
  ]
24218
24145
  }),
24219
24146
  parent
24147
+ })
24148
+ }
24149
+
24150
+ function unquoteSqlId (identifier) {
24151
+ const match = identifier.match(/^`(.*)`$/);
24152
+ return match ? match[1] : identifier
24153
+ }
24154
+
24155
+ function createTable (columns, rows, id, headerRenderer, cellRenderer) {
24156
+ const tableElement = document.createElement('table');
24157
+ if (id) tableElement.id = id;
24158
+ const theadElement = document.createElement('thead');
24159
+ const headerTrElement = document.createElement('tr');
24160
+ const tbodyElement = document.createElement('tbody');
24161
+ theadElement.appendChild(headerTrElement);
24162
+ tableElement.appendChild(theadElement);
24163
+ tableElement.appendChild(tbodyElement);
24164
+
24165
+ columns.forEach(function (columnName) {
24166
+ if (headerRenderer) {
24167
+ headerRenderer(headerTrElement, columnName);
24168
+ } else {
24169
+ const headerElement = document.createElement('th');
24170
+ headerElement.innerText = columnName;
24171
+ headerTrElement.appendChild(headerElement);
24172
+ }
24173
+ });
24174
+ if (columns.length > 0) {
24175
+ headerTrElement.appendChild(document.createElement('th'));
24176
+ }
24177
+ let highlight = false;
24178
+ rows.forEach(function (row) {
24179
+ const rowElement = document.createElement('tr');
24180
+ if (highlight) {
24181
+ rowElement.classList.add('highlighted-row');
24182
+ }
24183
+ highlight = !highlight;
24184
+ tbodyElement.appendChild(rowElement);
24185
+ row.forEach(function (value, i) {
24186
+ if (cellRenderer) {
24187
+ cellRenderer(rowElement, columns[i], value);
24188
+ } else {
24189
+ const cellElement = document.createElement('td');
24190
+ cellElement.innerText = value;
24191
+ rowElement.appendChild(cellElement);
24192
+ }
24193
+ });
24194
+ rowElement.appendChild(document.createElement('td'));
24195
+ });
24196
+ return tableElement
24197
+ }
24198
+
24199
+ /* global google */
24200
+
24201
+ function getSqlFromUrl (url) {
24202
+ const params = url.searchParams;
24203
+ if (params.has('file') && params.has('sql')) {
24204
+ // TODO: show an error.
24205
+ throw new Error('You can only specify a file or sql param, not both.')
24206
+ }
24207
+ if (params.has('sql')) {
24208
+ return params.get('sql')
24209
+ } else if (params.has('file')) {
24210
+ const file = params.get('file');
24211
+ const fileDetails = window.metadata.saved[file];
24212
+ if (!fileDetails) throw new Error(`no such file: ${file}`)
24213
+ return fileDetails.contents
24214
+ }
24215
+ throw new Error('You must specify a file or sql param')
24216
+ }
24217
+
24218
+ function init (parent, onSubmit, onShiftSubmit) {
24219
+ addClickListener(document.getElementById('query-tab-button'), (event) => selectTab(event, 'query'));
24220
+ addClickListener(document.getElementById('saved-tab-button'), (event) => selectTab(event, 'saved'));
24221
+ addClickListener(document.getElementById('structure-tab-button'), (event) => selectTab(event, 'structure'));
24222
+ addClickListener(document.getElementById('graph-tab-button'), (event) => selectTab(event, 'graph'));
24223
+ addClickListener(document.getElementById('cancel-button'), (event) => clearResult());
24224
+
24225
+ const dropdownContent = document.getElementById('submit-dropdown-content');
24226
+ const dropdownButton = document.getElementById('submit-dropdown-button');
24227
+ addClickListener(dropdownButton, () => dropdownContent.classList.toggle('submit-dropdown-content-show'));
24228
+
24229
+ const isMac = navigator.userAgent.includes('Mac');
24230
+ const runCurrentLabel = `run selection (${isMac ? '⌘' : 'Ctrl'}-Enter)`;
24231
+ const runAllLabel = `run all (${isMac ? '⌘' : 'Ctrl'}-Shift-Enter)`;
24232
+
24233
+ const submitButtonCurrent = document.getElementById('submit-button-current');
24234
+ submitButtonCurrent.value = runCurrentLabel;
24235
+ addClickListener(submitButtonCurrent, (event) => submitCurrent(event.target, event));
24236
+
24237
+ const submitButtonAll = document.getElementById('submit-button-all');
24238
+ submitButtonAll.value = runAllLabel;
24239
+ addClickListener(submitButtonAll, (event) => submitAll(event.target, event));
24240
+
24241
+ const dropdownButtonCurrent = document.getElementById('submit-dropdown-button-current');
24242
+ dropdownButtonCurrent.value = runCurrentLabel;
24243
+ addClickListener(dropdownButtonCurrent, (event) => submitCurrent(event.target, event));
24244
+
24245
+ const dropdownAllButton = document.getElementById('submit-dropdown-button-all');
24246
+ dropdownAllButton.value = runAllLabel;
24247
+ addClickListener(dropdownAllButton, (event) => submitAll(event.target, event));
24248
+
24249
+ const dropdownToggleButton = document.getElementById('submit-dropdown-button-toggle');
24250
+ addClickListener(dropdownToggleButton, () => {
24251
+ submitButtonCurrent.classList.toggle('submit-button-show');
24252
+ submitButtonAll.classList.toggle('submit-button-show');
24253
+ focus(getSelection());
24254
+ });
24255
+
24256
+ addClickListener(document.getElementById('submit-dropdown-button-copy-csv'), (event) => {
24257
+ if (window.sqlFetch?.result) {
24258
+ copyTextToClipboard(toCsv(window.sqlFetch.result.columns, window.sqlFetch.result.rows));
24259
+ }
24260
+ });
24261
+ addClickListener(document.getElementById('submit-dropdown-button-copy-tsv'), (event) => {
24262
+ if (window.sqlFetch?.result) {
24263
+ copyTextToClipboard(toTsv(window.sqlFetch.result.columns, window.sqlFetch.result.rows));
24264
+ }
24265
+ });
24266
+ addClickListener(document.getElementById('submit-dropdown-button-download-csv'), () => {
24267
+ if (!window.sqlFetch?.result) return
24268
+
24269
+ const url = new URL(window.location);
24270
+ url.searchParams.set('sql', base64Encode(getSqlFromUrl(url)));
24271
+ url.searchParams.delete('file');
24272
+ setActionInUrl(url, 'download_csv');
24273
+
24274
+ const link = document.createElement('a');
24275
+ link.setAttribute('download', 'result.csv');
24276
+ link.setAttribute('href', url.href);
24277
+ link.click();
24278
+
24279
+ focus(getSelection());
24280
+ });
24281
+
24282
+ document.addEventListener('click', function (event) {
24283
+ if (event.target !== dropdownButton) {
24284
+ dropdownContent.classList.remove('submit-dropdown-content-show');
24285
+ }
24286
+ });
24287
+ dropdownContent.addEventListener('focusout', function (event) {
24288
+ if (!dropdownContent.contains(event.relatedTarget)) {
24289
+ dropdownContent.classList.remove('submit-dropdown-content-show');
24290
+ }
24220
24291
  });
24292
+ window.editorView = createEditor(parent, window.metadata, onSubmit, onShiftSubmit);
24221
24293
  }
24222
24294
 
24223
24295
  function addClickListener (element, func) {
@@ -24438,7 +24510,7 @@
24438
24510
  }
24439
24511
  rows.push(row);
24440
24512
  }
24441
- createTable(columnsElement, columns, rows);
24513
+ columnsElement.appendChild(createTable(columns, rows, null));
24442
24514
  }
24443
24515
 
24444
24516
  const indexEntries = Object.entries(table.indexes);
@@ -24458,49 +24530,12 @@
24458
24530
  rows.push(row);
24459
24531
  }
24460
24532
  }
24461
- createTable(indexesElement, columns, rows);
24533
+ indexesElement.appendChild(createTable(columns, rows, null));
24462
24534
  }
24463
24535
  });
24464
24536
  window.structureLoaded = true;
24465
24537
  }
24466
24538
 
24467
- function createTable (parent, columns, rows) {
24468
- const tableElement = document.createElement('table');
24469
- const theadElement = document.createElement('thead');
24470
- const headerTrElement = document.createElement('tr');
24471
- const tbodyElement = document.createElement('tbody');
24472
- theadElement.appendChild(headerTrElement);
24473
- tableElement.appendChild(theadElement);
24474
- tableElement.appendChild(tbodyElement);
24475
- parent.appendChild(tableElement);
24476
-
24477
- columns.forEach(function (columnName) {
24478
- const headerElement = document.createElement('th');
24479
- headerElement.classList.add('cell');
24480
- headerElement.innerText = columnName;
24481
- headerTrElement.appendChild(headerElement);
24482
- });
24483
- if (columns.length > 0) {
24484
- headerTrElement.appendChild(document.createElement('th'));
24485
- }
24486
- let highlight = false;
24487
- rows.forEach(function (row) {
24488
- const rowElement = document.createElement('tr');
24489
- if (highlight) {
24490
- rowElement.classList.add('highlighted-row');
24491
- }
24492
- highlight = !highlight;
24493
- tbodyElement.appendChild(rowElement);
24494
- row.forEach(function (value) {
24495
- const cellElement = document.createElement('td');
24496
- cellElement.classList.add('cell');
24497
- cellElement.innerText = value;
24498
- rowElement.appendChild(cellElement);
24499
- });
24500
- rowElement.appendChild(document.createElement('td'));
24501
- });
24502
- }
24503
-
24504
24539
  function selectGraphTab (internal) {
24505
24540
  document.getElementById('query-box').style.display = 'flex';
24506
24541
  document.getElementById('submit-box').style.display = 'flex';
@@ -24892,39 +24927,46 @@
24892
24927
  clearResultBox();
24893
24928
  displaySqlFetchResultStatus('result-status', fetch);
24894
24929
 
24895
- const tableElement = document.createElement('table');
24896
- tableElement.id = 'result-table';
24897
- const theadElement = document.createElement('thead');
24898
- const headerElement = document.createElement('tr');
24899
- const tbodyElement = document.createElement('tbody');
24900
- theadElement.appendChild(headerElement);
24901
- tableElement.appendChild(theadElement);
24902
- tableElement.appendChild(tbodyElement);
24903
- document.getElementById('result-box').appendChild(tableElement);
24930
+ const createLink = function (link, value) {
24931
+ const linkElement = document.createElement('a');
24932
+ linkElement.href = link.template.replaceAll('{*}', encodeURIComponent(value));
24933
+ linkElement.innerText = link.short_name;
24934
+ linkElement.target = '_blank';
24904
24935
 
24905
- fetch.result.columns.forEach(column => {
24906
- const template = document.createElement('template');
24907
- template.innerHTML = `<th class="cell">${column}</th>`;
24908
- headerElement.appendChild(template.content.firstChild);
24909
- });
24910
- if (fetch.result.columns.length > 0) {
24911
- headerElement.appendChild(document.createElement('th'));
24912
- }
24913
- let highlight = false;
24914
- fetch.result.rows.forEach(function (row) {
24915
- const rowElement = document.createElement('tr');
24916
- if (highlight) {
24917
- rowElement.classList.add('highlighted-row');
24936
+ const abbrElement = document.createElement('abbr');
24937
+ abbrElement.title = link.long_name;
24938
+ abbrElement.appendChild(linkElement);
24939
+
24940
+ return abbrElement
24941
+ };
24942
+
24943
+ const headerRenderer = function (rowElement, column) {
24944
+ const headerElement = document.createElement('th');
24945
+ headerElement.innerText = column;
24946
+ if (window.metadata.columns[column]) {
24947
+ headerElement.colSpan = 2;
24918
24948
  }
24919
- highlight = !highlight;
24920
- tbodyElement.appendChild(rowElement);
24921
- row.forEach(function (value) {
24922
- const template = document.createElement('template');
24923
- template.innerHTML = `<td class="cell">${value}</td>`;
24924
- rowElement.appendChild(template.content.firstChild);
24925
- });
24926
- rowElement.appendChild(document.createElement('td'));
24927
- });
24949
+ rowElement.appendChild(headerElement);
24950
+ };
24951
+
24952
+ const cellRenderer = function (rowElement, column, value) {
24953
+ if (window.metadata.columns[column]?.links?.length > 0) {
24954
+ const linksColumnElement = document.createElement('td');
24955
+ window.metadata.columns[column].links.forEach((link) => {
24956
+ linksColumnElement.appendChild(createLink(link, value));
24957
+ });
24958
+ rowElement.appendChild(linksColumnElement);
24959
+ const textColumnElement = document.createElement('td');
24960
+ textColumnElement.innerText = value;
24961
+ rowElement.appendChild(textColumnElement);
24962
+ } else {
24963
+ const cellElement = document.createElement('td');
24964
+ cellElement.innerText = value;
24965
+ rowElement.appendChild(cellElement);
24966
+ }
24967
+ };
24968
+ document.getElementById('result-box')
24969
+ .appendChild(createTable(fetch.result.columns, fetch.result.rows, 'result-table', headerRenderer, cellRenderer));
24928
24970
  }
24929
24971
 
24930
24972
  function disableDownloadButtons () {
@@ -25116,10 +25158,12 @@
25116
25158
  if (contentType && contentType.indexOf('application/json') !== -1) {
25117
25159
  return response.json().then((result) => {
25118
25160
  if (result.error) {
25119
- let error = `<pre>${result.error}`;
25161
+ let error = '<div style="font-family: monospace; font-size: 16px;">\n';
25162
+ error += `<div>${result.error}</div>\n`;
25120
25163
  if (result.stacktrace) {
25121
- error += '\n' + result.stacktrace + '</pre>';
25164
+ error += '<pre>\n' + result.stacktrace + '\n</pre>\n';
25122
25165
  }
25166
+ error += '</div>\n';
25123
25167
  document.getElementById('loading-box').innerHTML = error;
25124
25168
  } else if (!result.server) {
25125
25169
  document.getElementById('loading-box').innerHTML = `
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.47
4
+ version: 0.1.49
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-21 00:00:00.000000000 Z
11
+ date: 2022-11-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mysql2
@@ -140,7 +140,9 @@ files:
140
140
  - app/sqlui.rb
141
141
  - app/sqlui_config.rb
142
142
  - app/views/databases.erb
143
+ - app/views/error.erb
143
144
  - bin/sqlui
145
+ - client/resources/favicon.svg
144
146
  - client/resources/sqlui.css
145
147
  - client/resources/sqlui.html
146
148
  - client/resources/sqlui.js