sqlui 0.1.47 → 0.1.49

Sign up to get free protection for your applications and to get access to all the features.
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