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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69e4dde0aaa2c01991a06d6ac073494c10a22b6f55b4f40d0a7dedc5f5ab2e7a
4
- data.tar.gz: 5321247d38c5845e5398b8cb6560e6581a389bac11cf9ea06e5ace893c7f0b5d
3
+ metadata.gz: 1bf6c0615e6ef20a8ccdb39cb45a50dbc1ce2a5ed465f0c92de054d63577c27b
4
+ data.tar.gz: ff7a3de7803138292bf86594ce224c09557156189486212d5aee0b83447d8553
5
5
  SHA512:
6
- metadata.gz: 032dc0a5c4a53e8273b2cf9f2916dd3d322d2c10c2e3ccf9a067471cb029bdeed34c22889071220da9d91f83e4e3976c90ef2eeb8327c84a0de88675e60e9f33
7
- data.tar.gz: 7a39174c4c5b2b19cd0adf03a51a522c40b577cac9cd16bfcce1cd32ede24816aa292cc8388d960172853c06420b56bba8193320650c5fa8bf26b40ebdabe3df
6
+ metadata.gz: 9db790262959d363d28b658148931b3e98aedb03f4f728f3c0a268e111a6d9b038b591949680bc5abf3dcf1118652d0cc4489109a478c4527bdb951590e49e35
7
+ data.tar.gz: 9771a25e3c63baeeb5fc3a8d71b275496c96656f2f12fac2996d17b2fc8a82b1666932d1b8fc11757bf2b54666f03eec2173cbcb11de420e7e72be8e8a2609b5
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.40
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
 
@@ -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)
@@ -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, column_name, ordinal_position;
41
+ order by table_schema, table_name, ordinal_position;
42
42
  SQL
43
43
  )
44
- column_result.each do |row|
45
- row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
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[:table_name]
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[:column_name]
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[:data_type]
66
- column[:length] = row[:character_maximum_length]
67
- column[:allow_null] = row[:is_nullable]
68
- column[:key] = row[:column_key]
69
- column[:default] = row[:column_default]
70
- column[:extra] = row[:extra]
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
- row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
99
- table_schema = row[:table_schema]
97
+ table_schema = row.shift
100
98
  tables = result[table_schema][:tables]
101
- table_name = row[:table_name]
99
+ table_name = row.shift
102
100
  indexes = tables[table_name][:indexes]
103
- index_name = row[:index_name]
101
+ index_name = row.shift
104
102
  indexes[index_name] = {} unless indexes[index_name]
105
103
  index = indexes[index_name]
106
- column_name = row[:column_name]
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[:seq_in_index]
111
- column[:non_unique] = row[:non_unique]
112
- column[:column_name] = row[: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
- if params[:selection]
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
- variables.each do |name, value|
112
- client.query("SET @#{name} = #{value};")
113
- end
114
- execute_query(client, sql)
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] = full_sql
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 execute_query(client, sql)
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
- results = queries.map { |current| client.query(current) }
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
@@ -5,7 +5,7 @@ require_relative 'server'
5
5
 
6
6
  # Main entry point.
7
7
  class Sqlui
8
- MAX_ROWS = 1_000
8
+ MAX_ROWS = 10_000
9
9
 
10
10
  def initialize(config_file)
11
11
  raise 'you must specify a configuration file' unless config_file
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)
@@ -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;
@@ -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>
@@ -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 open = cursor.openStart;
3362
+ let openRanges = cursor.openStart;
3363
3363
  for (;;) {
3364
3364
  let curTo = Math.min(cursor.to, to);
3365
3365
  if (cursor.point) {
3366
- iterator.point(pos, curTo, cursor.point, cursor.activeForPoint(cursor.to), open, cursor.pointRank);
3367
- open = cursor.openEnd(curTo) + (cursor.to > curTo ? 1 : 0);
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, open);
3371
- open = cursor.openEnd(curTo);
3372
+ iterator.span(pos, curTo, cursor.active, openRanges);
3373
+ openRanges = cursor.openEnd(curTo);
3372
3374
  }
3373
3375
  if (cursor.to > to)
3374
- break;
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, trackExtra = 0;
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
- let openStart = 0;
3772
- while (openStart < trackOpen.length && trackOpen[openStart] < from)
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(0, openStart));
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[tableName];
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: '`' + qualifiedTableName + '` ' + alias,
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 scs = schemaCompletionSource(sqlConfig);
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 = scs(context);
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
- if (node?.name === 'Statement') {
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?.name === 'Script') {
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?.name)) {
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
- node = node.prevSibling;
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: keywordCompletionSource(MySQL, true)
24178
+ autocomplete: combinedKeywordCompletionSource
24086
24179
  })
24087
24180
  ]
24088
24181
  );
@@ -24168,8 +24261,8 @@
24168
24261
  });
24169
24262
  }
24170
24263
 
24171
- function setTabInUrl (url, tab) {
24172
- url.pathname = url.pathname.replace(/\/[^/]+$/, `/${tab}`);
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
- setTabInUrl(url, 'graph');
24279
+ setActionInUrl(url, 'graph');
24187
24280
  document.getElementById('graph-tab-button').href = url.pathname + url.search;
24188
- setTabInUrl(url, 'saved');
24281
+ setActionInUrl(url, 'saved');
24189
24282
  document.getElementById('saved-tab-button').href = url.pathname + url.search;
24190
- setTabInUrl(url, 'structure');
24283
+ setActionInUrl(url, 'structure');
24191
24284
  document.getElementById('structure-tab-button').href = url.pathname + url.search;
24192
- setTabInUrl(url, 'query');
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
- setTabInUrl(url, tab);
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
- setTabInUrl(viewUrl, 'query');
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
- setTabInUrl(runUrl, 'query');
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.40
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-18 00:00:00.000000000 Z
11
+ date: 2022-11-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mysql2