sqlui 0.1.19 → 0.1.21

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: 3e42ae906ab341a224ba0904cf74be9224a13e6284e2ead198a382926e84db34
4
- data.tar.gz: bd8f33a32f30179057914b9f291ff4af084f6917d8123f2bba550d5974df9b6f
3
+ metadata.gz: 1781b502f4bf82b9a87461d495cb6776f9290bfb5a2809a7ddb12e992ce7cc5c
4
+ data.tar.gz: a992696d1bbd1f809290035fa23c7cfec262f827073665e9013254420794d6d4
5
5
  SHA512:
6
- metadata.gz: c1f4f3c0f0e180b6aea398da508f7bc551edd19fd77e24f60825bfb33c7f4130e1299770a160947d27854cc7dbd26d76fda6436528561b82720b0652815208dc
7
- data.tar.gz: 1e275114bf9e63d2182c1a4a2a2390ebe97a14ad5b15d1b21ac0e276fbadce766203e42546cf09b6bbdaf9e0bc4c8abc451aa2756dc02334b2dc89f403ae6447
6
+ metadata.gz: b6bfc097c3e9f427aec918c9b3eabeef74a69a3109fff6588c2a87201c9f43a7a0839463ec1cbe400b5d880ee1c3f069e6ecea6978acb02fd152633cc841f448
7
+ data.tar.gz: 9ec1cfb0f64cc425331723856e89aab3d2fac02f107a381a7c7ab6b2f1c4f0ee40adbe87b458c14fb537fe72ecb5eff044f02812e82fceb9c1263cff369a6ee3
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.19
1
+ 0.1.21
data/app/args.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Argument validation.
4
+ class Args
5
+ def self.fetch_non_empty_string(hash, key)
6
+ value = fetch_non_nil(hash, key, String)
7
+ raise ArgumentError, "required parameter #{key} empty" if value.strip.empty?
8
+
9
+ value
10
+ end
11
+
12
+ def self.fetch_non_empty_hash(hash, key)
13
+ value = fetch_non_nil(hash, key, Hash)
14
+ raise ArgumentError, "required parameter #{key} empty" if value.empty?
15
+
16
+ value
17
+ end
18
+
19
+ def self.fetch_non_nil(hash, key, *classes)
20
+ raise ArgumentError, "required parameter #{key} missing" unless hash.key?(key)
21
+
22
+ value = hash[key]
23
+ raise ArgumentError, "required parameter #{key} null" if value.nil?
24
+
25
+ if classes.size.positive? && !classes.find { |clazz| value.is_a?(clazz) }
26
+ raise ArgumentError, "required parameter #{key} not a #{classes[0].to_s.downcase}" if classes.size == 1
27
+
28
+ raise ArgumentError, "required parameter #{key} not #{classes.map(&:to_s).map(&:downcase).join(' or ')}"
29
+ end
30
+
31
+ value
32
+ end
33
+ end
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mysql2'
4
+ require_relative 'args'
4
5
 
5
6
  # Config for a single database.
6
7
  class DatabaseConfig
7
- attr_reader :display_name, :description, :url_path, :saved_path, :client_params, :database
8
+ attr_reader :display_name, :description, :url_path, :saved_path, :client_params
8
9
 
9
10
  def initialize(hash)
10
- @display_name = hash[:display_name].strip
11
- @description = hash[:description].strip
12
- @url_path = hash[:url_path].strip
11
+ @display_name = Args.fetch_non_empty_string(hash, :display_name).strip
12
+ @description = Args.fetch_non_empty_string(hash, :description).strip
13
+ @url_path = Args.fetch_non_empty_string(hash, :url_path).strip
13
14
  raise ArgumentError, 'url_path should start with a /' unless @url_path.start_with?('/')
14
15
  raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
15
16
 
16
- @saved_path = hash[:saved_path].strip
17
- @client_params = hash[:client_params]
18
- @database = @client_params[:database].strip
17
+ @saved_path = Args.fetch_non_empty_string(hash, :saved_path).strip
18
+ @client_params = Args.fetch_non_empty_hash(hash, :client_params)
19
19
  end
20
20
 
21
21
  def with_client(&block)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads metadata from a database.
4
+ class DatabaseMetadata
5
+ def initialize
6
+ raise "can't instantiate #{self.class}"
7
+ end
8
+
9
+ class << self
10
+ def lookup(client, database_config)
11
+ result = load_columns(client, database_config)
12
+ load_stats(result, client, database_config)
13
+
14
+ result
15
+ end
16
+
17
+ private
18
+
19
+ def load_columns(client, database_config)
20
+ result = {}
21
+ database = database_config.client_params[:database]
22
+ where_clause = if database
23
+ "where table_schema = '#{database}'"
24
+ else
25
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
26
+ end
27
+ column_result = client.query(
28
+ <<~SQL
29
+ select
30
+ table_schema,
31
+ table_name,
32
+ column_name,
33
+ data_type,
34
+ character_maximum_length,
35
+ is_nullable,
36
+ column_key,
37
+ column_default,
38
+ extra
39
+ from information_schema.columns
40
+ #{where_clause}
41
+ order by table_schema, table_name, column_name, ordinal_position;
42
+ SQL
43
+ )
44
+ column_result.each do |row|
45
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
46
+ table_schema = row[:table_schema]
47
+ unless result[table_schema]
48
+ result[table_schema] = {
49
+ tables: {}
50
+ }
51
+ end
52
+ table_name = row[:table_name]
53
+ tables = result[table_schema][:tables]
54
+ unless tables[table_name]
55
+ tables[table_name] = {
56
+ indexes: {},
57
+ columns: {}
58
+ }
59
+ end
60
+ columns = result[table_schema][:tables][table_name][:columns]
61
+ column_name = row[:column_name]
62
+ columns[column_name] = {} unless columns[column_name]
63
+ column = columns[column_name]
64
+ 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]
71
+ end
72
+ result
73
+ end
74
+
75
+ def load_stats(result, client, database_config)
76
+ database = database_config.client_params[:database]
77
+
78
+ where_clause = if database
79
+ "where table_schema = '#{database}'"
80
+ else
81
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
82
+ end
83
+ stats_result = client.query(
84
+ <<~SQL
85
+ select
86
+ table_schema,
87
+ table_name,
88
+ index_name,
89
+ seq_in_index,
90
+ non_unique,
91
+ column_name
92
+ from information_schema.statistics
93
+ #{where_clause}
94
+ order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
95
+ SQL
96
+ )
97
+ stats_result.each do |row|
98
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
99
+ table_schema = row[:table_schema]
100
+ tables = result[table_schema][:tables]
101
+ table_name = row[:table_name]
102
+ indexes = tables[table_name][:indexes]
103
+ index_name = row[:index_name]
104
+ indexes[index_name] = {} unless indexes[index_name]
105
+ index = indexes[index_name]
106
+ column_name = row[:column_name]
107
+ index[column_name] = {}
108
+ column = index[column_name]
109
+ 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]
113
+ end
114
+ end
115
+ end
116
+ end
data/app/deep.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deep extensions for Enumerable.
4
+ module Enumerable
5
+ def deep_transform_keys!(&block)
6
+ each { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
7
+ self
8
+ end
9
+
10
+ def deep_dup(result = {})
11
+ map do |value|
12
+ value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
13
+ end
14
+ result
15
+ end
16
+ end
17
+
18
+ # Deep extensions for Hash.
19
+ class Hash
20
+ def deep_transform_keys!(&block)
21
+ transform_keys!(&:to_s)
22
+ each_value { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
23
+ self
24
+ end
25
+
26
+ def deep_dup(result = {})
27
+ each do |key, value|
28
+ result[key] = value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
29
+ end
30
+ result
31
+ end
32
+
33
+ def deep_set(*path, value:)
34
+ raise ArgumentError, 'no path specified' if path.empty?
35
+
36
+ if path.size == 1
37
+ self[path[0]] = value
38
+ else
39
+ raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
40
+ raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
41
+
42
+ self.[](path[0]).deep_set(*path[1..], value: value)
43
+ end
44
+ end
45
+
46
+ def deep_delete(*path)
47
+ raise ArgumentError, 'no path specified' if path.empty?
48
+ raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
49
+
50
+ if path.size == 1
51
+ delete(path[0])
52
+ else
53
+ raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
54
+
55
+ self.[](path[0]).deep_delete(*path[1..])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Maps MySQL data types to type descriptions the client can use.
4
+ class MysqlTypes
5
+ def initialize
6
+ raise "can't instantiate #{self.class}"
7
+ end
8
+
9
+ class << self
10
+ def map_to_google_charts_types(types)
11
+ types.map do |type|
12
+ type = type.gsub(/\([^)]*\)/, '').strip
13
+ case type
14
+ when 'bool', 'boolean'
15
+ 'boolean'
16
+ when /double/, 'bigint', 'bit', 'dec', 'decimal', 'float', 'int', 'integer', 'mediumint', 'smallint', 'tinyint'
17
+ 'number'
18
+ when /char/, /binary/, /blob/, /text/, 'enum', 'set', /image/
19
+ 'string'
20
+ when 'date', 'year'
21
+ 'date'
22
+ when 'datetime', 'timestamp'
23
+ 'datetime'
24
+ when 'time'
25
+ 'timeofday'
26
+ else
27
+ puts "unexpected MySQL type: #{type}"
28
+ 'string'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
data/app/server.rb CHANGED
@@ -4,112 +4,137 @@ require 'erb'
4
4
  require 'json'
5
5
  require 'sinatra/base'
6
6
  require 'uri'
7
+ require_relative 'database_metadata'
7
8
  require_relative 'environment'
9
+ require_relative 'mysql_types'
8
10
  require_relative 'sql_parser'
9
- require_relative 'sqlui_config'
10
-
11
- if ARGV.include?('-v') || ARGV.include?('--version')
12
- puts File.read('.version')
13
- exit
14
- end
15
-
16
- raise 'you must specify a configuration file' unless ARGV.size == 1
17
- raise 'configuration file does not exist' unless File.exist?(ARGV[0])
11
+ require_relative 'sqlui'
18
12
 
19
13
  # SQLUI Sinatra server.
20
14
  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
15
+ def self.init_and_run(config, resources_dir)
16
+ set :logging, true
17
+ set :bind, '0.0.0.0'
18
+ set :port, Environment.server_port
19
+ set :env, Environment.server_env
20
+ set :raise_errors, false
21
+ set :show_exceptions, false
22
+
23
+ get '/-/health' do
24
+ status 200
25
+ body 'OK'
26
+ end
30
27
 
31
- set :logging, true
32
- set :bind, '0.0.0.0'
33
- set :port, Environment.server_port
34
- set :env, Environment.server_env
28
+ get "#{config.list_url_path}/?" do
29
+ erb :databases, locals: { config: config }
30
+ end
35
31
 
36
- get '/-/health' do
37
- status 200
38
- body 'OK'
39
- end
32
+ config.database_configs.each do |database|
33
+ get database.url_path.to_s do
34
+ redirect "#{database.url_path}/app", 301
35
+ end
40
36
 
41
- get "#{CONFIG.list_url_path}/?" do
42
- erb :databases, locals: { config: @config }
43
- end
37
+ get "#{database.url_path}/app" do
38
+ @html ||= File.read(File.join(resources_dir, 'sqlui.html'))
39
+ status 200
40
+ headers 'Content-Type': 'text/html'
41
+ body @html
42
+ end
44
43
 
45
- CONFIG.database_configs.each do |database|
46
- get database.url_path.to_s do
47
- redirect "#{params[:database]}/app", 301
48
- end
44
+ get "#{database.url_path}/sqlui.css" do
45
+ @css ||= File.read(File.join(resources_dir, 'sqlui.css'))
46
+ status 200
47
+ headers 'Content-Type': 'text/css'
48
+ body @css
49
+ end
49
50
 
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
51
+ get "#{database.url_path}/sqlui.js" do
52
+ @js ||= File.read(File.join(resources_dir, 'sqlui.js'))
53
+ status 200
54
+ headers 'Content-Type': 'text/javascript'
55
+ body @js
56
+ end
56
57
 
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
58
+ get "#{database.url_path}/metadata" do
59
+ metadata = database.with_client do |client|
60
+ {
61
+ server: config.name,
62
+ schemas: DatabaseMetadata.lookup(client, database),
63
+ saved: Dir.glob("#{database.saved_path}/*.sql").map do |path|
64
+ comment_lines = File.readlines(path).take_while do |l|
65
+ l.start_with?('--')
66
+ end
67
+ description = comment_lines.map { |l| l.sub(/^-- */, '') }.join
68
+ {
69
+ filename: File.basename(path),
70
+ description: description
71
+ }
72
+ end
73
+ }
74
+ end
75
+ status 200
76
+ headers 'Content-Type': 'application/json'
77
+ body metadata.to_json
78
+ end
63
79
 
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
80
+ get "#{database.url_path}/query_file" do
81
+ break client_error('missing file param') unless params[:file]
70
82
 
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)
75
- end
76
- status 200
77
- headers 'Content-Type': 'application/json'
78
- body metadata.to_json
79
- end
83
+ file = File.join(database.saved_path, params[:file])
84
+ break client_error('no such file') unless File.exist?(file)
80
85
 
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])
86
+ sql = File.read(file)
87
+ result = database.with_client do |client|
88
+ execute_query(client, sql).tap { |r| r[:file] = params[:file] }
89
+ end
84
90
 
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] }
91
+ status 200
92
+ headers 'Content-Type': 'application/json'
93
+ body result.to_json
89
94
  end
90
95
 
91
- status 200
92
- headers 'Content-Type': 'application/json'
93
- body result.to_json
94
- end
96
+ post "#{database.url_path}/query" do
97
+ params.merge!(JSON.parse(request.body.read, symbolize_names: true))
98
+ break client_error('missing sql') unless params[:sql]
99
+
100
+ sql = params[:sql]
101
+ selection = params[:selection]
102
+ if selection
103
+ selection = selection.split(':').map { |v| Integer(v) }
104
+
105
+ sql = if selection[0] == selection[1]
106
+ SqlParser.find_statement_at_cursor(params[:sql], selection[0])
107
+ else
108
+ params[:sql][selection[0], selection[1]]
109
+ end
110
+ break client_error("can't find query at selection") unless sql
111
+ end
95
112
 
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]
113
+ result = database.with_client do |client|
114
+ execute_query(client, sql)
115
+ end
100
116
 
101
- sql = SqlParser.find_statement_at_cursor(params[:sql], Integer(params[:cursor]))
102
- raise "can't find query at cursor" unless sql
117
+ result[:selection] = params[:selection]
103
118
 
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)
119
+ status 200
120
+ headers 'Content-Type': 'application/json'
121
+ body result.to_json
107
122
  end
123
+ end
108
124
 
109
- status 200
125
+ error do |e|
126
+ status 500
110
127
  headers 'Content-Type': 'application/json'
128
+ message = e.message.lines.first&.strip || 'unexpected error'
129
+ message = "#{message[0..80]}…" if message.length > 80
130
+ result = {
131
+ error: message,
132
+ stacktrace: e.backtrace.map { |b| b }.join("\n")
133
+ }
111
134
  body result.to_json
112
135
  end
136
+
137
+ run!
113
138
  end
114
139
 
115
140
  private
@@ -117,151 +142,34 @@ class Server < Sinatra::Base
117
142
  def client_error(message, stacktrace: nil)
118
143
  status(400)
119
144
  headers('Content-Type': 'application/json')
120
- body({ message: message, stacktrace: stacktrace }.compact.to_json)
121
- end
122
-
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
145
+ body({ error: message, stacktrace: stacktrace }.compact.to_json)
224
146
  end
225
147
 
226
148
  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
149
+ queries = if sql.include?(';')
150
+ sql.split(/(?<=;)/).map(&:strip).reject(&:empty?)
151
+ else
152
+ [sql]
153
+ end
154
+ results = queries.map { |current| client.query(current) }
155
+ result = results[-1]
156
+ # NOTE: the call to result.field_types must go before any other interaction with the result. Otherwise you will
157
+ # get a seg fault. Seems to be a bug in Mysql2.
158
+ if result
159
+ column_types = MysqlTypes.map_to_google_charts_types(result.field_types)
160
+ rows = result.map(&:values)
161
+ columns = result.first&.keys || []
162
+ else
163
+ column_types = []
164
+ rows = []
165
+ columns = []
256
166
  end
257
167
  {
258
168
  query: sql,
259
169
  columns: columns,
260
170
  column_types: column_types,
261
171
  total_rows: rows.size,
262
- rows: rows.take(MAX_ROWS)
172
+ rows: rows.take(Sqlui::MAX_ROWS)
263
173
  }
264
174
  end
265
-
266
- run!
267
175
  end