2015-06-25 12:25:15 -04:00
|
|
|
# name: discourse-data-explorer
|
|
|
|
# about: Interface for running analysis SQL queries on the live database
|
|
|
|
# version: 0.1
|
|
|
|
# authors: Riking
|
|
|
|
# url: https://github.com/discourse/discourse-data-explorer
|
|
|
|
|
|
|
|
enabled_site_setting :data_explorer_enabled
|
|
|
|
register_asset 'stylesheets/tagging.scss'
|
|
|
|
|
2015-06-25 13:43:05 -04:00
|
|
|
# route: /admin/plugins/explorer
|
|
|
|
add_admin_route 'explorer.title', 'explorer'
|
|
|
|
|
|
|
|
module ::DataExplorer
|
|
|
|
def self.plugin_name
|
2015-06-25 14:58:14 -04:00
|
|
|
'discourse-data-explorer'.freeze
|
2015-06-25 13:43:05 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.pstore_get(key)
|
|
|
|
PluginStore.get(DataExplorer.plugin_name, key)
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.pstore_set(key, value)
|
|
|
|
PluginStore.set(DataExplorer.plugin_name, key, value)
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.pstore_delete(key)
|
|
|
|
PluginStore.remove(DataExplorer.plugin_name, key)
|
|
|
|
end
|
|
|
|
end
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 13:43:05 -04:00
|
|
|
|
|
|
|
after_initialize do
|
2015-06-25 12:25:15 -04:00
|
|
|
|
|
|
|
module ::DataExplorer
|
|
|
|
class Engine < ::Rails::Engine
|
|
|
|
engine_name "data_explorer"
|
|
|
|
isolate_namespace DataExplorer
|
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
class ValidationError < StandardError; end
|
|
|
|
|
|
|
|
# Extract :colon-style parameters from the SQL query and replace them with
|
|
|
|
# $1-style parameters.
|
|
|
|
#
|
|
|
|
# @return [Hash] :sql => [String] the new SQL query to run, :names =>
|
|
|
|
# [Array] the names of all parameters, in order by their $-style name.
|
|
|
|
# (The first name is $0.)
|
|
|
|
def self.extract_params(sql)
|
|
|
|
names = []
|
|
|
|
new_sql = sql.gsub(/:([a-z_]+)/) do |_|
|
|
|
|
names << $1
|
|
|
|
"$#{names.length - 1}"
|
|
|
|
end
|
|
|
|
{sql: new_sql, names: names}
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
# Run a data explorer query on the currently connected database.
|
|
|
|
#
|
|
|
|
# @param [DataExplorer::Query] query the Query object to run
|
|
|
|
# @param [Hash] params the colon-style query parameters to pass to AR
|
|
|
|
# @param [Hash] opts hash of options
|
|
|
|
# explain - include a query plan in the result
|
|
|
|
# @return [Hash]
|
|
|
|
# error - any exception that was raised in the execution. Check this
|
|
|
|
# first before looking at any other fields.
|
|
|
|
# pg_result - the PG::Result object
|
|
|
|
# duration_nanos - the query duration, in nanoseconds
|
|
|
|
# explain - the query
|
|
|
|
def self.run_query(query, params={}, opts={})
|
|
|
|
# Safety checks
|
|
|
|
if query.sql =~ /;/
|
|
|
|
err = DataExplorer::ValidationError.new(I18n.t('js.errors.explorer.no_semicolons'))
|
|
|
|
return {error: err, duration_nanos: 0}
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
query_args = query.defaults.merge(params)
|
|
|
|
|
2015-06-25 17:53:03 -04:00
|
|
|
time_start, time_end, explain, err, result = nil
|
2015-06-25 14:58:14 -04:00
|
|
|
begin
|
|
|
|
ActiveRecord::Base.connection.transaction do
|
|
|
|
# Setting transaction to read only prevents shoot-in-foot actions like SELECT FOR UPDATE
|
|
|
|
ActiveRecord::Base.exec_sql "SET TRANSACTION READ ONLY"
|
|
|
|
# SQL comments are for the benefits of the slow queries log
|
|
|
|
sql = <<SQL
|
2015-06-25 17:55:06 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
/*
|
2015-06-25 17:55:06 -04:00
|
|
|
* DataExplorer Query
|
|
|
|
* Query: /admin/plugins/explorer/#{query.id}
|
|
|
|
* Started by: #{opts[:current_user]}
|
|
|
|
*/
|
2015-06-25 14:58:14 -04:00
|
|
|
WITH query AS (
|
|
|
|
#{query.sql}
|
|
|
|
) SELECT * FROM query
|
2015-06-25 17:55:06 -04:00
|
|
|
LIMIT #{opts[:limit] || 1000}
|
2015-06-25 14:58:14 -04:00
|
|
|
SQL
|
|
|
|
|
|
|
|
time_start = Time.now
|
|
|
|
result = ActiveRecord::Base.exec_sql(sql, query_args)
|
|
|
|
time_end = Time.now
|
|
|
|
|
|
|
|
if opts[:explain]
|
|
|
|
explain = ActiveRecord::Base.exec_sql("EXPLAIN #{query.sql}", query_args)
|
|
|
|
.map { |row| row["QUERY PLAN"] }.join "\n"
|
|
|
|
end
|
|
|
|
|
|
|
|
# All done. Issue a rollback anyways, just in case
|
|
|
|
raise ActiveRecord::Rollback
|
|
|
|
end
|
|
|
|
rescue Exception => ex
|
|
|
|
err = ex
|
|
|
|
time_end = Time.now
|
|
|
|
end
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
{
|
|
|
|
error: err,
|
|
|
|
pg_result: result,
|
|
|
|
duration_nanos: time_end.nsec - time_start.nsec,
|
|
|
|
explain: explain,
|
|
|
|
}
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2015-06-25 13:43:05 -04:00
|
|
|
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
|
|
|
|
class DataExplorer::Query
|
2015-06-25 14:58:14 -04:00
|
|
|
attr_accessor :id, :name, :description, :sql, :defaults
|
|
|
|
|
|
|
|
def param_names
|
|
|
|
param_info = DataExplorer.extract_params sql
|
|
|
|
param_info[:names]
|
|
|
|
end
|
2015-06-25 13:43:05 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def slug
|
|
|
|
s = Slug.for(name)
|
|
|
|
s = "query-#{id}" unless s.present?
|
|
|
|
s
|
|
|
|
end
|
|
|
|
|
|
|
|
# saving/loading functions
|
|
|
|
# May want to extract this into a library or something for plugins to use?
|
2015-06-25 13:43:05 -04:00
|
|
|
def self.alloc_id
|
|
|
|
DistributedMutex.synchronize('data-explorer_query-id') do
|
|
|
|
max_id = DataExplorer.pstore_get("q:_id")
|
2015-06-25 17:53:03 -04:00
|
|
|
max_id = 1 unless max_id
|
2015-06-25 13:43:05 -04:00
|
|
|
DataExplorer.pstore_set("q:_id", max_id + 1)
|
|
|
|
max_id
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.from_hash(h)
|
|
|
|
query = DataExplorer::Query.new
|
2015-06-25 14:58:14 -04:00
|
|
|
[:name, :description, :sql].each do |sym|
|
|
|
|
query.send("#{sym}=", h[sym]) if h[sym]
|
|
|
|
end
|
|
|
|
if h[:id]
|
|
|
|
query.id = h[:id].to_i
|
|
|
|
end
|
|
|
|
if h[:defaults]
|
|
|
|
case h[:defaults]
|
|
|
|
when String
|
|
|
|
query.defaults = MultiJson.load(h[:defaults])
|
|
|
|
when Hash
|
|
|
|
query.defaults = h[:defaults]
|
|
|
|
else
|
|
|
|
raise ArgumentError.new('invalid type for :defaults')
|
|
|
|
end
|
2015-06-25 13:43:05 -04:00
|
|
|
end
|
|
|
|
query
|
|
|
|
end
|
|
|
|
|
|
|
|
def to_hash
|
|
|
|
{
|
|
|
|
id: @id,
|
2015-06-25 14:58:14 -04:00
|
|
|
name: @name || 'Query',
|
|
|
|
description: @description || '',
|
|
|
|
sql: @sql || 'SELECT 1',
|
|
|
|
defaults: @defaults || {},
|
2015-06-25 13:43:05 -04:00
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.find(id)
|
|
|
|
from_hash DataExplorer.pstore_get("q:#{id}")
|
|
|
|
end
|
|
|
|
|
|
|
|
def save
|
2015-06-25 17:53:03 -04:00
|
|
|
unless @id && @id > 0
|
2015-06-25 13:43:05 -04:00
|
|
|
@id = self.class.alloc_id
|
|
|
|
end
|
|
|
|
DataExplorer.pstore_set "q:#{id}", to_hash
|
|
|
|
end
|
|
|
|
|
|
|
|
def destroy
|
|
|
|
DataExplorer.pstore_delete "q:#{id}"
|
|
|
|
end
|
|
|
|
|
2015-06-25 17:53:03 -04:00
|
|
|
def read_attribute_for_serialization(attr)
|
|
|
|
self.send(attr)
|
|
|
|
end
|
|
|
|
|
2015-06-25 13:43:05 -04:00
|
|
|
def self.all
|
2015-06-25 17:53:03 -04:00
|
|
|
PluginStoreRow.where(plugin_name: DataExplorer.plugin_name)
|
|
|
|
.where("key LIKE 'q:%'")
|
|
|
|
.where("key != 'q:_id'")
|
|
|
|
.map do |psr|
|
2015-06-25 13:43:05 -04:00
|
|
|
DataExplorer::Query.from_hash PluginStore.cast_value(psr.type_name, psr.value)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-06-25 12:25:15 -04:00
|
|
|
require_dependency 'application_controller'
|
2015-06-25 16:26:31 -04:00
|
|
|
class DataExplorer::QueryController < ::ApplicationController
|
2015-06-25 14:58:14 -04:00
|
|
|
requires_plugin DataExplorer.plugin_name
|
|
|
|
skip_before_filter :check_xhr, only: [:show]
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def index
|
|
|
|
# guardian.ensure_can_use_data_explorer!
|
|
|
|
queries = DataExplorer::Query.all
|
2015-06-25 16:26:31 -04:00
|
|
|
render_serialized queries, DataExplorer::QuerySerializer, root: 'queries'
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def show
|
2015-06-25 14:58:14 -04:00
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
if params[:export]
|
|
|
|
response.headers['Content-Disposition'] = "attachment; filename=#{query.slug}.json"
|
|
|
|
response.sending_file = true
|
|
|
|
else
|
|
|
|
check_xhr
|
|
|
|
end
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
# guardian.ensure_can_see! query
|
2015-06-25 16:26:31 -04:00
|
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'queries'
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
# Helper endpoint for logic
|
|
|
|
def parse_params
|
|
|
|
render json: (DataExplorer.extract_params params.require(:sql))[:names]
|
|
|
|
end
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def create
|
|
|
|
# guardian.ensure_can_create_explorer_query!
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
query = DataExplorer::Query.from_hash params.permit(:name, :sql, :defaults)
|
|
|
|
# Set the ID _only_ if undeleting
|
|
|
|
if params[:recover]
|
|
|
|
query.id = params[:id].to_i
|
|
|
|
end
|
|
|
|
query.save
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 16:26:31 -04:00
|
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'queries'
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def update
|
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
|
|
[:name, :sql, :defaults].each do |sym|
|
|
|
|
query.send("#{sym}=", params[sym]) if params[sym]
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
2015-06-25 14:58:14 -04:00
|
|
|
query.save
|
2015-06-25 12:25:15 -04:00
|
|
|
|
2015-06-25 16:26:31 -04:00
|
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'queries'
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def destroy
|
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
|
|
query.destroy
|
2015-06-25 16:26:31 -04:00
|
|
|
render nothing: true
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
def run
|
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
|
|
query_params = MultiJson.load(params[:params])
|
2015-06-25 17:53:03 -04:00
|
|
|
opts = {current_user: current_user.username}
|
2015-06-25 14:58:14 -04:00
|
|
|
opts[:explain] = true if params[:explain]
|
|
|
|
result = DataExplorer.run_query(query, query_params, opts)
|
|
|
|
|
|
|
|
if result[:error]
|
|
|
|
err = result[:error]
|
|
|
|
|
|
|
|
# Pretty printing logic
|
|
|
|
err_class = err.class
|
|
|
|
err_msg = err.message
|
|
|
|
if err.is_a? ActiveRecord::StatementInvalid
|
|
|
|
err_class = err.original_exception.class
|
|
|
|
err_msg.gsub!("#{err_class.to_s}:", '')
|
|
|
|
else
|
|
|
|
err_msg = "#{err_class}: #{err_msg}"
|
|
|
|
end
|
|
|
|
|
|
|
|
render json: {
|
|
|
|
success: false,
|
|
|
|
errors: [err_msg]
|
|
|
|
}
|
|
|
|
else
|
|
|
|
pg_result = result[:pg_result]
|
|
|
|
cols = pg_result.fields
|
|
|
|
json = {
|
|
|
|
success: true,
|
|
|
|
errors: [],
|
|
|
|
params: query_params,
|
2015-06-25 17:53:03 -04:00
|
|
|
duration: (result[:duration_nanos].to_f / 1_000_000).round(1),
|
2015-06-25 14:58:14 -04:00
|
|
|
columns: cols,
|
|
|
|
}
|
|
|
|
json[:explain] = result[:explain] if opts[:explain]
|
|
|
|
# TODO - special serialization
|
|
|
|
# if cols.any? { |col_name| special_serialization? col_name }
|
|
|
|
# json[:relations] = DataExplorer.add_extra_data(pg_result)
|
|
|
|
# end
|
|
|
|
|
|
|
|
# TODO - can we tweak this to save network traffic
|
|
|
|
json[:rows] = pg_result.to_a
|
|
|
|
|
|
|
|
render json: json
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
class DataExplorer::QuerySerializer < ActiveModel::Serializer
|
|
|
|
attributes :id, :sql, :name, :description, :defaults
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
DataExplorer::Engine.routes.draw do
|
2015-06-25 16:26:31 -04:00
|
|
|
root to: "query#index"
|
|
|
|
get 'queries' => "query#index"
|
|
|
|
# POST /query -> explorer#create
|
|
|
|
# GET /query/:id -> explorer#show
|
|
|
|
# PUT /query/:id -> explorer#update
|
|
|
|
# DELETE /query/:id -> explorer#destroy
|
|
|
|
resources :query
|
|
|
|
get 'query/parse_params' => "query#parse_params"
|
|
|
|
post 'query/:id/run' => "query#run"
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
2015-06-25 14:58:14 -04:00
|
|
|
Discourse::Application.routes.append do
|
2015-06-25 16:26:31 -04:00
|
|
|
mount ::DataExplorer::Engine, at: '/admin/plugins/explorer', constraints: AdminConstraint.new
|
2015-06-25 12:25:15 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|