sqlui 0.1.40 → 0.1.42

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: 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