sqlui 0.1.18 → 0.1.19

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: f951e672b477e0fd4882c1aa4e42472975ac07fa6a2955eb4430bc1018ea6165
4
- data.tar.gz: d8e7da6fe289ed3e94322a3ea3d5ce461356cbfa73e2e2732bab9001a8d3af24
3
+ metadata.gz: 3e42ae906ab341a224ba0904cf74be9224a13e6284e2ead198a382926e84db34
4
+ data.tar.gz: bd8f33a32f30179057914b9f291ff4af084f6917d8123f2bba550d5974df9b6f
5
5
  SHA512:
6
- metadata.gz: 6769e3ee3c0b5330ca0b1ac1433370756838cec3187f0bd9822308704d31ee1608592199acb9b622edba8d45146c557e413d42c0a060bb9ace9b7653b43ad54c
7
- data.tar.gz: 4ef70417125cdb931c5f6edba2ef7986be7bda22f0f27c362d7bea97df81752133493e9507b941cbfe51261fa4e77aca85f4838354805a97d88e64f0240e452d
6
+ metadata.gz: c1f4f3c0f0e180b6aea398da508f7bc551edd19fd77e24f60825bfb33c7f4130e1299770a160947d27854cc7dbd26d76fda6436528561b82720b0652815208dc
7
+ data.tar.gz: 1e275114bf9e63d2182c1a4a2a2390ebe97a14ad5b15d1b21ac0e276fbadce766203e42546cf09b6bbdaf9e0bc4c8abc451aa2756dc02334b2dc89f403ae6447
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.18
1
+ 0.1.19
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mysql2'
4
+
5
+ # Config for a single database.
6
+ class DatabaseConfig
7
+ attr_reader :display_name, :description, :url_path, :saved_path, :client_params, :database
8
+
9
+ def initialize(hash)
10
+ @display_name = hash[:display_name].strip
11
+ @description = hash[:description].strip
12
+ @url_path = hash[:url_path].strip
13
+ raise ArgumentError, 'url_path should start with a /' unless @url_path.start_with?('/')
14
+ raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
15
+
16
+ @saved_path = hash[:saved_path].strip
17
+ @client_params = hash[:client_params]
18
+ @database = @client_params[:database].strip
19
+ end
20
+
21
+ def with_client(&block)
22
+ client = Mysql2::Client.new(@client_params)
23
+ result = block.call(client)
24
+ client.close
25
+ client = nil
26
+ result
27
+ ensure
28
+ client&.close
29
+ end
30
+ end
data/app/server.rb CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  require 'erb'
4
4
  require 'json'
5
- require 'mysql2'
6
5
  require 'sinatra/base'
7
- require_relative 'sqlui'
8
- require 'yaml'
6
+ require 'uri'
9
7
  require_relative 'environment'
8
+ require_relative 'sql_parser'
9
+ require_relative 'sqlui_config'
10
10
 
11
11
  if ARGV.include?('-v') || ARGV.include?('--version')
12
12
  puts File.read('.version')
@@ -18,78 +18,249 @@ raise 'configuration file does not exist' unless File.exist?(ARGV[0])
18
18
 
19
19
  # SQLUI Sinatra server.
20
20
  class Server < Sinatra::Base
21
+ MAX_ROWS = 1_000
22
+
23
+ CONFIG = SqluiConfig.new(ARGV[0])
24
+
25
+ def initialize(app = nil, **_kwargs)
26
+ super
27
+ @config = Server::CONFIG
28
+ @resources_dir = File.join(File.expand_path('..', File.dirname(__FILE__)), 'client', 'resources')
29
+ end
30
+
21
31
  set :logging, true
22
32
  set :bind, '0.0.0.0'
23
33
  set :port, Environment.server_port
24
34
  set :env, Environment.server_env
25
35
 
26
- # A MySQL client. This needs to go away.
27
- class Client
28
- def initialize(params)
29
- @params = params
36
+ get '/-/health' do
37
+ status 200
38
+ body 'OK'
39
+ end
40
+
41
+ get "#{CONFIG.list_url_path}/?" do
42
+ erb :databases, locals: { config: @config }
43
+ end
44
+
45
+ CONFIG.database_configs.each do |database|
46
+ get database.url_path.to_s do
47
+ redirect "#{params[:database]}/app", 301
30
48
  end
31
49
 
32
- def query(sql)
33
- client = Thread.current.thread_variable_get(:client)
34
- unless client
35
- client = Mysql2::Client.new(@params)
36
- Thread.current.thread_variable_set(:client, client)
50
+ get "#{database.url_path}/app" do
51
+ @html ||= File.read(File.join(@resources_dir, 'sqlui.html'))
52
+ status 200
53
+ headers 'Content-Type': 'text/html'
54
+ body @html
55
+ end
56
+
57
+ get "#{database.url_path}/sqlui.css" do
58
+ @css ||= File.read(File.join(@resources_dir, 'sqlui.css'))
59
+ status 200
60
+ headers 'Content-Type': 'text/css'
61
+ body @css
62
+ end
63
+
64
+ get "#{database.url_path}/sqlui.js" do
65
+ @js ||= File.read(File.join(@resources_dir, 'sqlui.js'))
66
+ status 200
67
+ headers 'Content-Type': 'text/javascript'
68
+ body @js
69
+ end
70
+
71
+ get "#{database.url_path}/metadata" do
72
+ database_config = @config.database_config_for(url_path: database.url_path)
73
+ metadata = database_config.with_client do |client|
74
+ load_metadata(client, database_config.database, database_config.saved_path)
37
75
  end
38
- client.query(sql)
76
+ status 200
77
+ headers 'Content-Type': 'application/json'
78
+ body metadata.to_json
39
79
  end
40
- end
41
80
 
42
- config = YAML.safe_load(ERB.new(File.read(ARGV[0])).result)
43
- saved_path_root = config['saved_path']
44
- client_map = config['databases'].values.to_h do |database_config|
45
- client_params = {
46
- host: database_config['db_host'],
47
- port: database_config['db_port'] || 3306,
48
- username: database_config['db_username'],
49
- password: database_config['db_password'],
50
- database: database_config['db_database'],
51
- read_timeout: 10, # seconds
52
- write_timeout: 0, # seconds
53
- connect_timeout: 5 # seconds
54
- }
55
- client = Client.new(client_params)
56
- [
57
- database_config['url_path'],
58
- ::SQLUI.new(
59
- client: client,
60
- table_schema: database_config['db_database'],
61
- name: database_config['name'],
62
- saved_path: File.join(saved_path_root, database_config['saved_path'])
63
- )
64
- ]
65
- end
81
+ get "#{database.url_path}/query_file" do
82
+ return client_error('missing file param') unless params[:file]
83
+ return client_error('no such file') unless File.exist?(params[:file])
66
84
 
67
- get '/-/health' do
68
- status 200
69
- body 'OK'
70
- end
85
+ database_config = @config.database_config_for(url_path: database.url_path)
86
+ sql = File.read(File.join(database_config.saved_path, params[:file]))
87
+ result = database_config.with_client do |client|
88
+ execute_query(client, sql).tap { |r| r[:file] = params[:file] }
89
+ end
90
+
91
+ status 200
92
+ headers 'Content-Type': 'application/json'
93
+ body result.to_json
94
+ end
95
+
96
+ post "#{database.url_path}/query" do
97
+ params.merge!(JSON.parse(request.body.read, symbolize_names: true))
98
+ return client_error('missing sql') unless params[:sql]
99
+ return client_error('missing cursor') unless params[:cursor]
100
+
101
+ sql = SqlParser.find_statement_at_cursor(params[:sql], Integer(params[:cursor]))
102
+ raise "can't find query at cursor" unless sql
71
103
 
72
- get '/db/?' do
73
- erb :databases, locals: { databases: config['databases'] }
104
+ database_config = @config.database_config_for(url_path: database.url_path)
105
+ result = database_config.with_client do |client|
106
+ execute_query(client, sql)
107
+ end
108
+
109
+ status 200
110
+ headers 'Content-Type': 'application/json'
111
+ body result.to_json
112
+ end
74
113
  end
75
114
 
76
- get '/db/:database' do
77
- redirect "/db/#{params[:database]}/app", 301
115
+ private
116
+
117
+ def client_error(message, stacktrace: nil)
118
+ status(400)
119
+ headers('Content-Type': 'application/json')
120
+ body({ message: message, stacktrace: stacktrace }.compact.to_json)
78
121
  end
79
122
 
80
- get '/db/:database/:route' do
81
- response = client_map[params[:database]].get(params)
82
- status response[:status]
83
- headers 'Content-Type': response[:content_type]
84
- body response[:body]
123
+ def load_metadata(client, database, saved_path)
124
+ result = {
125
+ server: @config.name,
126
+ schemas: {},
127
+ saved: Dir.glob("#{saved_path}/*.sql").map do |path|
128
+ {
129
+ filename: File.basename(path),
130
+ description: File.readlines(path).take_while { |l| l.start_with?('--') }.map { |l| l.sub(/^-- */, '') }.join
131
+ }
132
+ end
133
+ }
134
+
135
+ where_clause = if database
136
+ "where table_schema = '#{database}'"
137
+ else
138
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
139
+ end
140
+ column_result = client.query(
141
+ <<~SQL
142
+ select
143
+ table_schema,
144
+ table_name,
145
+ column_name,
146
+ data_type,
147
+ character_maximum_length,
148
+ is_nullable,
149
+ column_key,
150
+ column_default,
151
+ extra
152
+ from information_schema.columns
153
+ #{where_clause}
154
+ order by table_schema, table_name, column_name, ordinal_position;
155
+ SQL
156
+ )
157
+ column_result.each do |row|
158
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
159
+ table_schema = row[:table_schema]
160
+ unless result[:schemas][table_schema]
161
+ result[:schemas][table_schema] = {
162
+ tables: {}
163
+ }
164
+ end
165
+ table_name = row[:table_name]
166
+ tables = result[:schemas][table_schema][:tables]
167
+ unless tables[table_name]
168
+ tables[table_name] = {
169
+ indexes: {},
170
+ columns: {}
171
+ }
172
+ end
173
+ columns = result[:schemas][table_schema][:tables][table_name][:columns]
174
+ column_name = row[:column_name]
175
+ columns[column_name] = {} unless columns[column_name]
176
+ column = columns[column_name]
177
+ column[:name] = column_name
178
+ column[:data_type] = row[:data_type]
179
+ column[:length] = row[:character_maximum_length]
180
+ column[:allow_null] = row[:is_nullable]
181
+ column[:key] = row[:column_key]
182
+ column[:default] = row[:column_default]
183
+ column[:extra] = row[:extra]
184
+ end
185
+
186
+ where_clause = if database
187
+ "where table_schema = '#{database}'"
188
+ else
189
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
190
+ end
191
+ stats_result = client.query(
192
+ <<~SQL
193
+ select
194
+ table_schema,
195
+ table_name,
196
+ index_name,
197
+ seq_in_index,
198
+ non_unique,
199
+ column_name
200
+ from information_schema.statistics
201
+ #{where_clause}
202
+ order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
203
+ SQL
204
+ )
205
+ stats_result.each do |row|
206
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
207
+ table_schema = row[:table_schema]
208
+ tables = result[:schemas][table_schema][:tables]
209
+ table_name = row[:table_name]
210
+ indexes = tables[table_name][:indexes]
211
+ index_name = row[:index_name]
212
+ indexes[index_name] = {} unless indexes[index_name]
213
+ index = indexes[index_name]
214
+ column_name = row[:column_name]
215
+ index[column_name] = {}
216
+ column = index[column_name]
217
+ column[:name] = index_name
218
+ column[:seq_in_index] = row[:seq_in_index]
219
+ column[:non_unique] = row[:non_unique]
220
+ column[:column_name] = row[:column_name]
221
+ end
222
+
223
+ result
85
224
  end
86
225
 
87
- post '/db/:database/:route' do
88
- post_body = JSON.parse(request.body.read)
89
- response = client_map[params[:database]].post(params.merge(post_body))
90
- status response[:status]
91
- headers 'Content-Type': response[:content_type]
92
- body response[:body]
226
+ def execute_query(client, sql)
227
+ result = client.query(sql, cast: false)
228
+ rows = result.map(&:values)
229
+ columns = result.first&.keys || []
230
+ # TODO: use field_types
231
+ column_types = columns.map { |_| 'string' }
232
+ unless rows.empty?
233
+ maybe_non_null_column_value_exemplars = columns.each_with_index.map do |_, index|
234
+ row = rows.find do |current|
235
+ !current[index].nil?
236
+ end
237
+ row.nil? ? nil : row[index]
238
+ end
239
+ column_types = maybe_non_null_column_value_exemplars.map do |value|
240
+ case value
241
+ when String, NilClass
242
+ 'string'
243
+ when Integer, Float
244
+ 'number'
245
+ when Date
246
+ 'date'
247
+ when Time
248
+ 'datetime'
249
+ when TrueClass, FalseClass
250
+ 'boolean'
251
+ else
252
+ # TODO: report an error
253
+ value.class.to_s
254
+ end
255
+ end
256
+ end
257
+ {
258
+ query: sql,
259
+ columns: columns,
260
+ column_types: column_types,
261
+ total_rows: rows.size,
262
+ rows: rows.take(MAX_ROWS)
263
+ }
93
264
  end
94
265
 
95
266
  run!
data/app/sql_parser.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Used to parse strings containing one or more SQL statements.
4
+ class SqlParser
5
+ def self.find_statement_at_cursor(sql, cursor)
6
+ parts_with_ranges = []
7
+ sql.scan(/[^;]*;[ \n]*/) { |part| parts_with_ranges << [part, 0, part.size] }
8
+ parts_with_ranges.inject(0) do |pos, current|
9
+ current[1] += pos
10
+ current[2] += pos
11
+ end
12
+ part_with_range = parts_with_ranges.find do |current|
13
+ cursor >= current[1] && cursor < current[2]
14
+ end || parts_with_ranges[-1]
15
+ part_with_range[0].strip
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'database_config'
5
+
6
+ # App config including database configs.
7
+ class SqluiConfig
8
+ attr_reader :name, :list_url_path, :database_configs
9
+
10
+ def initialize(filename)
11
+ config = YAML.safe_load(ERB.new(File.read(filename)).result)
12
+ deep_symbolize!(config)
13
+ @name = fetch_non_empty_string(config, :name).strip
14
+ @list_url_path = fetch_non_empty_string(config, :list_url_path).strip
15
+ raise ArgumentError, 'list_url_path should start with a /' unless @list_url_path.start_with?('/')
16
+ if @list_url_path.length > 1 && @list_url_path.end_with?('/')
17
+ raise ArgumentError, 'list_url_path should not end with a /'
18
+ end
19
+
20
+ databases = config[:databases]
21
+ if databases.nil? || !databases.is_a?(Hash) || databases.empty?
22
+ raise ArgumentError, 'required parameter databases missing'
23
+ end
24
+
25
+ @database_configs = databases.map do |_, current|
26
+ DatabaseConfig.new(current)
27
+ end
28
+ end
29
+
30
+ def database_config_for(url_path:)
31
+ @database_configs.find { |database| database.url_path == url_path } || raise("no config found for path #{url_path}")
32
+ end
33
+
34
+ private
35
+
36
+ def fetch_non_empty_string(hash, key)
37
+ value = hash[key]
38
+ raise ArgumentError, "required parameter #{key} missing" if value.nil? || !value.is_a?(String) || value.strip.empty?
39
+
40
+ value.strip
41
+ end
42
+
43
+ def deep_symbolize!(object)
44
+ return unless object.is_a? Hash
45
+
46
+ object.transform_keys!(&:to_sym)
47
+ object.each_value { |child| deep_symbolize!(child) }
48
+ end
49
+ end
@@ -50,15 +50,15 @@
50
50
  </head>
51
51
 
52
52
  <body>
53
- <h1>Databases</h1>
54
- <% databases.values.each do |database| %>
55
- <div class="database" onclick="window.location='<%= "/db/#{database['url_path']}/app" %>'">
56
- <h2 class='name'><%= database['name'] %></h2>
57
- <a class='query-link' href="/db/<%= database['url_path'] %>/app">query</a>
58
- <a class='saved-link' href="/db/<%= database['url_path'] %>/app?tab=saved">saved</a>
59
- <a class='structure-link' href="/db/<%= database['url_path'] %>/app?tab=structure">structure</a>
53
+ <h1><% config.name %> Databases</h1>
54
+ <% config.database_configs.each do |database_config| %>
55
+ <div class="database" onclick="window.location='<%= "#{database_config.url_path}/app" %>'">
56
+ <h2 class='name'><%= database_config.display_name %></h2>
57
+ <a class='query-link' href="<%= database_config.url_path %>/app">query</a>
58
+ <a class='saved-link' href="<%= database_config.url_path %>/app?tab=saved">saved</a>
59
+ <a class='structure-link' href="<%= database_config.url_path %>/app?tab=structure">structure</a>
60
60
  <p class='description'>
61
- <%= database['description'] %>
61
+ <%= database_config.description %>
62
62
  </p>
63
63
  </div>
64
64
  <% end %>
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.18
4
+ version: 0.1.19
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-10-24 00:00:00.000000000 Z
11
+ date: 2022-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mysql2
@@ -158,9 +158,11 @@ extensions: []
158
158
  extra_rdoc_files: []
159
159
  files:
160
160
  - ".version"
161
+ - app/database_config.rb
161
162
  - app/environment.rb
162
163
  - app/server.rb
163
- - app/sqlui.rb
164
+ - app/sql_parser.rb
165
+ - app/sqlui_config.rb
164
166
  - app/views/databases.erb
165
167
  - bin/sqlui
166
168
  - client/resources/sqlui.css
data/app/sqlui.rb DELETED
@@ -1,230 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'uri'
5
- require 'set'
6
-
7
- # Main SQLUI class responsible for producing web content. This class needs to die.
8
- class SQLUI
9
- MAX_ROWS = 1_000
10
-
11
- def initialize(client:, name:, saved_path:, table_schema: nil, max_rows: MAX_ROWS)
12
- @client = client
13
- @table_schema = table_schema
14
- @name = name
15
- @saved_path = saved_path
16
- @max_rows = max_rows
17
- @resources_dir = File.join(File.expand_path('..', File.dirname(__FILE__)), 'client', 'resources')
18
- end
19
-
20
- def get(params)
21
- case params[:route]
22
- when 'app'
23
- { body: html, status: 200, content_type: 'text/html' }
24
- when 'sqlui.css'
25
- { body: css, status: 200, content_type: 'text/css' }
26
- when 'sqlui.js'
27
- { body: javascript, status: 200, content_type: 'text/javascript' }
28
- when 'metadata'
29
- { body: metadata.to_json, status: 200, content_type: 'application/json' }
30
- when 'query_file'
31
- { body: query_file(params).to_json, status: 200, content_type: 'application/json' }
32
- else
33
- { body: "unknown route: #{params[:route]}", status: 404, content_type: 'text/plain' }
34
- end
35
- end
36
-
37
- def post(params)
38
- case params[:route]
39
- when 'query'
40
- { body: query(params).to_json, status: 200, content_type: 'application/json' }
41
- else
42
- { body: "unknown route: #{params[:route]}", status: 404, content_type: 'text/plain' }
43
- end
44
- end
45
-
46
- private
47
-
48
- def html
49
- @html ||= File.read(File.join(@resources_dir, 'sqlui.html'))
50
- end
51
-
52
- def css
53
- @css ||= File.read(File.join(@resources_dir, 'sqlui.css'))
54
- end
55
-
56
- def javascript
57
- @javascript ||= File.read(File.join(@resources_dir, 'sqlui.js'))
58
- end
59
-
60
- def query(params)
61
- raise 'missing sql' unless params[:sql]
62
- raise 'missing cursor' unless params[:cursor]
63
-
64
- sql = find_query_at_cursor(params[:sql], Integer(params[:cursor]))
65
- raise "can't find query at cursor" unless sql
66
-
67
- execute_query(sql)
68
- end
69
-
70
- def query_file(params)
71
- raise 'missing file param' unless params['file']
72
-
73
- sql = File.read("#{@saved_path}/#{params['file']}")
74
- execute_query(sql).tap { |r| r[:file] = params[:file] }
75
- end
76
-
77
- def metadata
78
- load_metadata
79
- end
80
-
81
- def load_metadata
82
- result = {
83
- server: @name,
84
- schemas: {},
85
- saved: Dir.glob("#{@saved_path}/*.sql").map do |path|
86
- {
87
- filename: File.basename(path),
88
- description: File.readlines(path).take_while { |l| l.start_with?('--') }.map { |l| l.sub(/^-- */, '') }.join
89
- }
90
- end
91
- }
92
-
93
- where_clause = if @table_schema
94
- "where table_schema = '#{@table_schema}'"
95
- else
96
- "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
97
- end
98
- column_result = @client.query(
99
- <<~SQL
100
- select
101
- table_schema,
102
- table_name,
103
- column_name,
104
- data_type,
105
- character_maximum_length,
106
- is_nullable,
107
- column_key,
108
- column_default,
109
- extra
110
- from information_schema.columns
111
- #{where_clause}
112
- order by table_schema, table_name, column_name, ordinal_position;
113
- SQL
114
- )
115
- column_result.each do |row|
116
- row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
117
- table_schema = row[:table_schema]
118
- unless result[:schemas][table_schema]
119
- result[:schemas][table_schema] = {
120
- tables: {}
121
- }
122
- end
123
- table_name = row[:table_name]
124
- tables = result[:schemas][table_schema][:tables]
125
- unless tables[table_name]
126
- tables[table_name] = {
127
- indexes: {},
128
- columns: {}
129
- }
130
- end
131
- columns = result[:schemas][table_schema][:tables][table_name][:columns]
132
- column_name = row[:column_name]
133
- columns[column_name] = {} unless columns[column_name]
134
- column = columns[column_name]
135
- column[:name] = column_name
136
- column[:data_type] = row[:data_type]
137
- column[:length] = row[:character_maximum_length]
138
- column[:allow_null] = row[:is_nullable]
139
- column[:key] = row[:column_key]
140
- column[:default] = row[:column_default]
141
- column[:extra] = row[:extra]
142
- end
143
-
144
- where_clause = if @table_schema
145
- "where table_schema = '#{@table_schema}'"
146
- else
147
- "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
148
- end
149
- stats_result = @client.query(
150
- <<~SQL
151
- select
152
- table_schema,
153
- table_name,
154
- index_name,
155
- seq_in_index,
156
- non_unique,
157
- column_name
158
- from information_schema.statistics
159
- #{where_clause}
160
- order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
161
- SQL
162
- )
163
- stats_result.each do |row|
164
- row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
165
- table_schema = row[:table_schema]
166
- tables = result[:schemas][table_schema][:tables]
167
- table_name = row[:table_name]
168
- indexes = tables[table_name][:indexes]
169
- index_name = row[:index_name]
170
- indexes[index_name] = {} unless indexes[index_name]
171
- index = indexes[index_name]
172
- column_name = row[:column_name]
173
- index[column_name] = {}
174
- column = index[column_name]
175
- column[:name] = index_name
176
- column[:seq_in_index] = row[:seq_in_index]
177
- column[:non_unique] = row[:non_unique]
178
- column[:column_name] = row[:column_name]
179
- end
180
-
181
- result
182
- end
183
-
184
- def execute_query(sql)
185
- result = @client.query(sql)
186
- rows = result.map(&:values)
187
- columns = result.first&.keys || []
188
- column_types = columns.map { |_| 'string' }
189
- unless rows.empty?
190
- maybe_non_null_column_value_exemplars = columns.each_with_index.map do |_, index|
191
- row = rows.find do |current|
192
- !current[index].nil?
193
- end
194
- row.nil? ? nil : row[index]
195
- end
196
- column_types = maybe_non_null_column_value_exemplars.map do |value|
197
- case value
198
- when String, NilClass
199
- 'string'
200
- when Integer
201
- 'number'
202
- when Date, Time
203
- 'date'
204
- else
205
- value.class.to_s
206
- end
207
- end
208
- end
209
- {
210
- query: sql,
211
- columns: columns,
212
- column_types: column_types,
213
- total_rows: rows.size,
214
- rows: rows.take(@max_rows)
215
- }
216
- end
217
-
218
- def find_query_at_cursor(sql, cursor)
219
- parts_with_ranges = []
220
- sql.scan(/[^;]*;[ \n]*/) { |part| parts_with_ranges << [part, 0, part.size] }
221
- parts_with_ranges.inject(0) do |pos, current|
222
- current[1] += pos
223
- current[2] += pos
224
- end
225
- part_with_range = parts_with_ranges.find do |current|
226
- cursor >= current[1] && cursor < current[2]
227
- end || parts_with_ranges[-1]
228
- part_with_range[0]
229
- end
230
- end