app/server.rb in sqlui-0.1.18 vs app/server.rb in sqlui-0.1.19
- old
+ new
@@ -1,14 +1,14 @@
# frozen_string_literal: true
require 'erb'
require 'json'
-require 'mysql2'
require 'sinatra/base'
-require_relative 'sqlui'
-require 'yaml'
+require 'uri'
require_relative 'environment'
+require_relative 'sql_parser'
+require_relative 'sqlui_config'
if ARGV.include?('-v') || ARGV.include?('--version')
puts File.read('.version')
exit
end
@@ -16,81 +16,252 @@
raise 'you must specify a configuration file' unless ARGV.size == 1
raise 'configuration file does not exist' unless File.exist?(ARGV[0])
# SQLUI Sinatra server.
class Server < Sinatra::Base
+ MAX_ROWS = 1_000
+
+ CONFIG = SqluiConfig.new(ARGV[0])
+
+ def initialize(app = nil, **_kwargs)
+ super
+ @config = Server::CONFIG
+ @resources_dir = File.join(File.expand_path('..', File.dirname(__FILE__)), 'client', 'resources')
+ end
+
set :logging, true
set :bind, '0.0.0.0'
set :port, Environment.server_port
set :env, Environment.server_env
- # A MySQL client. This needs to go away.
- class Client
- def initialize(params)
- @params = params
+ get '/-/health' do
+ status 200
+ body 'OK'
+ end
+
+ get "#{CONFIG.list_url_path}/?" do
+ erb :databases, locals: { config: @config }
+ end
+
+ CONFIG.database_configs.each do |database|
+ get database.url_path.to_s do
+ redirect "#{params[:database]}/app", 301
end
- def query(sql)
- client = Thread.current.thread_variable_get(:client)
- unless client
- client = Mysql2::Client.new(@params)
- Thread.current.thread_variable_set(:client, client)
+ get "#{database.url_path}/app" do
+ @html ||= File.read(File.join(@resources_dir, 'sqlui.html'))
+ status 200
+ headers 'Content-Type': 'text/html'
+ body @html
+ end
+
+ get "#{database.url_path}/sqlui.css" do
+ @css ||= File.read(File.join(@resources_dir, 'sqlui.css'))
+ status 200
+ headers 'Content-Type': 'text/css'
+ body @css
+ end
+
+ get "#{database.url_path}/sqlui.js" do
+ @js ||= File.read(File.join(@resources_dir, 'sqlui.js'))
+ status 200
+ headers 'Content-Type': 'text/javascript'
+ body @js
+ end
+
+ get "#{database.url_path}/metadata" do
+ database_config = @config.database_config_for(url_path: database.url_path)
+ metadata = database_config.with_client do |client|
+ load_metadata(client, database_config.database, database_config.saved_path)
end
- client.query(sql)
+ status 200
+ headers 'Content-Type': 'application/json'
+ body metadata.to_json
end
- end
- config = YAML.safe_load(ERB.new(File.read(ARGV[0])).result)
- saved_path_root = config['saved_path']
- client_map = config['databases'].values.to_h do |database_config|
- client_params = {
- host: database_config['db_host'],
- port: database_config['db_port'] || 3306,
- username: database_config['db_username'],
- password: database_config['db_password'],
- database: database_config['db_database'],
- read_timeout: 10, # seconds
- write_timeout: 0, # seconds
- connect_timeout: 5 # seconds
- }
- client = Client.new(client_params)
- [
- database_config['url_path'],
- ::SQLUI.new(
- client: client,
- table_schema: database_config['db_database'],
- name: database_config['name'],
- saved_path: File.join(saved_path_root, database_config['saved_path'])
- )
- ]
- end
+ get "#{database.url_path}/query_file" do
+ return client_error('missing file param') unless params[:file]
+ return client_error('no such file') unless File.exist?(params[:file])
- get '/-/health' do
- status 200
- body 'OK'
- end
+ database_config = @config.database_config_for(url_path: database.url_path)
+ sql = File.read(File.join(database_config.saved_path, params[:file]))
+ result = database_config.with_client do |client|
+ execute_query(client, sql).tap { |r| r[:file] = params[:file] }
+ end
- get '/db/?' do
- erb :databases, locals: { databases: config['databases'] }
+ status 200
+ headers 'Content-Type': 'application/json'
+ body result.to_json
+ end
+
+ post "#{database.url_path}/query" do
+ params.merge!(JSON.parse(request.body.read, symbolize_names: true))
+ return client_error('missing sql') unless params[:sql]
+ return client_error('missing cursor') unless params[:cursor]
+
+ sql = SqlParser.find_statement_at_cursor(params[:sql], Integer(params[:cursor]))
+ raise "can't find query at cursor" unless sql
+
+ database_config = @config.database_config_for(url_path: database.url_path)
+ result = database_config.with_client do |client|
+ execute_query(client, sql)
+ end
+
+ status 200
+ headers 'Content-Type': 'application/json'
+ body result.to_json
+ end
end
- get '/db/:database' do
- redirect "/db/#{params[:database]}/app", 301
+ private
+
+ def client_error(message, stacktrace: nil)
+ status(400)
+ headers('Content-Type': 'application/json')
+ body({ message: message, stacktrace: stacktrace }.compact.to_json)
end
- get '/db/:database/:route' do
- response = client_map[params[:database]].get(params)
- status response[:status]
- headers 'Content-Type': response[:content_type]
- body response[:body]
+ def load_metadata(client, database, saved_path)
+ result = {
+ server: @config.name,
+ schemas: {},
+ saved: Dir.glob("#{saved_path}/*.sql").map do |path|
+ {
+ filename: File.basename(path),
+ description: File.readlines(path).take_while { |l| l.start_with?('--') }.map { |l| l.sub(/^-- */, '') }.join
+ }
+ end
+ }
+
+ where_clause = if database
+ "where table_schema = '#{database}'"
+ else
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
+ end
+ column_result = client.query(
+ <<~SQL
+ select
+ table_schema,
+ table_name,
+ column_name,
+ data_type,
+ character_maximum_length,
+ is_nullable,
+ column_key,
+ column_default,
+ extra
+ from information_schema.columns
+ #{where_clause}
+ order by table_schema, table_name, column_name, ordinal_position;
+ SQL
+ )
+ column_result.each do |row|
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
+ table_schema = row[:table_schema]
+ unless result[:schemas][table_schema]
+ result[:schemas][table_schema] = {
+ tables: {}
+ }
+ end
+ table_name = row[:table_name]
+ tables = result[:schemas][table_schema][:tables]
+ unless tables[table_name]
+ tables[table_name] = {
+ indexes: {},
+ columns: {}
+ }
+ end
+ columns = result[:schemas][table_schema][:tables][table_name][:columns]
+ column_name = row[:column_name]
+ columns[column_name] = {} unless columns[column_name]
+ column = columns[column_name]
+ column[:name] = column_name
+ column[:data_type] = row[:data_type]
+ column[:length] = row[:character_maximum_length]
+ column[:allow_null] = row[:is_nullable]
+ column[:key] = row[:column_key]
+ column[:default] = row[:column_default]
+ column[:extra] = row[:extra]
+ end
+
+ where_clause = if database
+ "where table_schema = '#{database}'"
+ else
+ "where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
+ end
+ stats_result = client.query(
+ <<~SQL
+ select
+ table_schema,
+ table_name,
+ index_name,
+ seq_in_index,
+ non_unique,
+ column_name
+ from information_schema.statistics
+ #{where_clause}
+ order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
+ SQL
+ )
+ stats_result.each do |row|
+ row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
+ table_schema = row[:table_schema]
+ tables = result[:schemas][table_schema][:tables]
+ table_name = row[:table_name]
+ indexes = tables[table_name][:indexes]
+ index_name = row[:index_name]
+ indexes[index_name] = {} unless indexes[index_name]
+ index = indexes[index_name]
+ column_name = row[:column_name]
+ index[column_name] = {}
+ column = index[column_name]
+ column[:name] = index_name
+ column[:seq_in_index] = row[:seq_in_index]
+ column[:non_unique] = row[:non_unique]
+ column[:column_name] = row[:column_name]
+ end
+
+ result
end
- post '/db/:database/:route' do
- post_body = JSON.parse(request.body.read)
- response = client_map[params[:database]].post(params.merge(post_body))
- status response[:status]
- headers 'Content-Type': response[:content_type]
- body response[:body]
+ def execute_query(client, sql)
+ result = client.query(sql, cast: false)
+ rows = result.map(&:values)
+ columns = result.first&.keys || []
+ # TODO: use field_types
+ column_types = columns.map { |_| 'string' }
+ unless rows.empty?
+ maybe_non_null_column_value_exemplars = columns.each_with_index.map do |_, index|
+ row = rows.find do |current|
+ !current[index].nil?
+ end
+ row.nil? ? nil : row[index]
+ end
+ column_types = maybe_non_null_column_value_exemplars.map do |value|
+ case value
+ when String, NilClass
+ 'string'
+ when Integer, Float
+ 'number'
+ when Date
+ 'date'
+ when Time
+ 'datetime'
+ when TrueClass, FalseClass
+ 'boolean'
+ else
+ # TODO: report an error
+ value.class.to_s
+ end
+ end
+ end
+ {
+ query: sql,
+ columns: columns,
+ column_types: column_types,
+ total_rows: rows.size,
+ rows: rows.take(MAX_ROWS)
+ }
end
run!
end