sqlui 0.1.19 → 0.1.20

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: f41becc73029aa0f4e09476edbe96d273ed59b6dfd778eed97728c9d8b957e8e
4
+ data.tar.gz: 845897a0a4ab1a39e2ee2e6b8e5688b2548c0f30a463ef48171bd4953c138c01
5
5
  SHA512:
6
- metadata.gz: c1f4f3c0f0e180b6aea398da508f7bc551edd19fd77e24f60825bfb33c7f4130e1299770a160947d27854cc7dbd26d76fda6436528561b82720b0652815208dc
7
- data.tar.gz: 1e275114bf9e63d2182c1a4a2a2390ebe97a14ad5b15d1b21ac0e276fbadce766203e42546cf09b6bbdaf9e0bc4c8abc451aa2756dc02334b2dc89f403ae6447
6
+ metadata.gz: 85a81eeb433be3d01e0ecbacc990515249cc620c50cb852639a15a35849d2a69461c5c975140c1cb3ba743ac5f719756c0d1c7c44eed6e869f368cd32fd7386c
7
+ data.tar.gz: 9ad8b11e30e9ef47689eb01a7eb961fe409a7a5b10b06f32cdab6ce16a1ac43adaf91ab5a2643f51095d50262309f67d1af09cf8b304d532668380105c65c1dc
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.19
1
+ 0.1.20
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.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,136 @@ 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
30
-
31
- set :logging, true
32
- set :bind, '0.0.0.0'
33
- set :port, Environment.server_port
34
- set :env, Environment.server_env
35
-
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
48
- end
49
-
50
- get "#{database.url_path}/app" do
51
- @html ||= File.read(File.join(@resources_dir, 'sqlui.html'))
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
52
24
  status 200
53
- headers 'Content-Type': 'text/html'
54
- body @html
25
+ body 'OK'
55
26
  end
56
27
 
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
28
+ get "#{config.list_url_path}/?" do
29
+ erb :databases, locals: { config: config }
62
30
  end
63
31
 
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
32
+ config.database_configs.each do |database|
33
+ get database.url_path.to_s do
34
+ redirect "#{database.url_path}/app", 301
35
+ end
70
36
 
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
+ 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
75
42
  end
76
- status 200
77
- headers 'Content-Type': 'application/json'
78
- body metadata.to_json
79
- end
80
43
 
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])
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
84
50
 
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] }
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
89
56
  end
90
57
 
91
- status 200
92
- headers 'Content-Type': 'application/json'
93
- body result.to_json
94
- end
58
+ get "#{database.url_path}/metadata" do
59
+ database_config = config.database_config_for(url_path: database.url_path)
60
+ metadata = database_config.with_client do |client|
61
+ {
62
+ server: config.name,
63
+ schemas: DatabaseMetadata.lookup(client, database_config),
64
+ saved: Dir.glob("#{database_config.saved_path}/*.sql").map do |path|
65
+ comment_lines = File.readlines(path).take_while do |l|
66
+ l.start_with?('--')
67
+ end
68
+ description = comment_lines.map { |l| l.sub(/^-- */, '') }.join
69
+ {
70
+ filename: File.basename(path),
71
+ description: description
72
+ }
73
+ end
74
+ }
75
+ end
76
+ status 200
77
+ headers 'Content-Type': 'application/json'
78
+ body metadata.to_json
79
+ end
95
80
 
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]
81
+ get "#{database.url_path}/query_file" do
82
+ break client_error('missing file param') unless params[:file]
83
+ break client_error('no such file') unless File.exist?(params[:file])
100
84
 
101
- sql = SqlParser.find_statement_at_cursor(params[:sql], Integer(params[:cursor]))
102
- raise "can't find query at cursor" unless sql
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
103
90
 
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)
91
+ status 200
92
+ headers 'Content-Type': 'application/json'
93
+ body result.to_json
107
94
  end
108
95
 
109
- status 200
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
112
+
113
+ database_config = config.database_config_for(url_path: database.url_path)
114
+ result = database_config.with_client do |client|
115
+ execute_query(client, sql)
116
+ end
117
+
118
+ result[:selection] = params[:selection]
119
+
120
+ status 200
121
+ headers 'Content-Type': 'application/json'
122
+ body result.to_json
123
+ end
124
+ end
125
+
126
+ error do |e|
127
+ status 500
110
128
  headers 'Content-Type': 'application/json'
129
+ result = {
130
+ error: e.message,
131
+ stacktrace: e.backtrace.map { |b| b }.join("\n")
132
+ }
111
133
  body result.to_json
112
134
  end
135
+
136
+ run!
113
137
  end
114
138
 
115
139
  private
@@ -117,151 +141,33 @@ class Server < Sinatra::Base
117
141
  def client_error(message, stacktrace: nil)
118
142
  status(400)
119
143
  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
144
+ body({ error: message, stacktrace: stacktrace }.compact.to_json)
224
145
  end
225
146
 
226
147
  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
148
+ if sql.include?(';')
149
+ results = sql.split(/(?<=;)/).map { |current| client.query(current) }
150
+ result = results[-1]
151
+ else
152
+ result = client.query(sql)
153
+ end
154
+ # NOTE: the call to result.field_types must go before any other interaction with the result. Otherwise you will
155
+ # get a seg fault. Seems to be a bug in Mysql2.
156
+ if result
157
+ column_types = MysqlTypes.map_to_google_charts_types(result.field_types)
158
+ rows = result.map(&:values)
159
+ columns = result.first&.keys || []
160
+ else
161
+ column_types = []
162
+ rows = []
163
+ columns = []
256
164
  end
257
165
  {
258
166
  query: sql,
259
167
  columns: columns,
260
168
  column_types: column_types,
261
169
  total_rows: rows.size,
262
- rows: rows.take(MAX_ROWS)
170
+ rows: rows.take(Sqlui::MAX_ROWS)
263
171
  }
264
172
  end
265
-
266
- run!
267
173
  end