2023-03-22 17:29:08 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module ::DiscourseDataExplorer
|
|
|
|
class QueryController < ApplicationController
|
|
|
|
requires_plugin PLUGIN_NAME
|
|
|
|
|
|
|
|
before_action :set_group, only: %i[group_reports_index group_reports_show group_reports_run]
|
|
|
|
before_action :set_query, only: %i[group_reports_show group_reports_run show update]
|
|
|
|
before_action :ensure_admin
|
|
|
|
|
|
|
|
skip_before_action :check_xhr, only: %i[show group_reports_run run]
|
|
|
|
skip_before_action :ensure_admin,
|
|
|
|
only: %i[group_reports_index group_reports_show group_reports_run]
|
|
|
|
|
|
|
|
def index
|
|
|
|
queries = Query.where(hidden: false).order(:last_run_at, :name).includes(:groups).to_a
|
|
|
|
|
|
|
|
database_queries_ids = Query.pluck(:id)
|
|
|
|
Queries.default.each do |params|
|
|
|
|
attributes = params.last
|
|
|
|
next if database_queries_ids.include?(attributes["id"])
|
|
|
|
query = Query.new
|
|
|
|
query.id = attributes["id"]
|
|
|
|
query.sql = attributes["sql"]
|
|
|
|
query.name = attributes["name"]
|
|
|
|
query.description = attributes["description"]
|
|
|
|
query.user_id = Discourse::SYSTEM_USER_ID.to_s
|
|
|
|
queries << query
|
|
|
|
end
|
|
|
|
|
|
|
|
render_serialized queries, QuerySerializer, root: "queries"
|
|
|
|
end
|
|
|
|
|
|
|
|
def show
|
|
|
|
check_xhr unless params[:export]
|
|
|
|
|
|
|
|
if params[:export]
|
|
|
|
response.headers["Content-Disposition"] = "attachment; filename=#{@query.slug}.dcquery.json"
|
|
|
|
response.sending_file = true
|
|
|
|
end
|
|
|
|
|
|
|
|
return raise Discourse::NotFound if !guardian.user_can_access_query?(@query) || @query.hidden
|
|
|
|
render_serialized @query, QuerySerializer, root: "query"
|
|
|
|
end
|
|
|
|
|
|
|
|
def groups
|
|
|
|
render json: Group.all.select(:id, :name), root: false
|
|
|
|
end
|
|
|
|
|
|
|
|
def group_reports_index
|
|
|
|
return raise Discourse::NotFound unless guardian.user_is_a_member_of_group?(@group)
|
|
|
|
|
|
|
|
respond_to do |format|
|
|
|
|
format.json do
|
|
|
|
queries = Query.for_group(@group)
|
|
|
|
render_serialized(queries, QuerySerializer, root: "queries")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def group_reports_show
|
|
|
|
if !guardian.group_and_user_can_access_query?(@group, @query) || @query.hidden
|
|
|
|
return raise Discourse::NotFound
|
|
|
|
end
|
|
|
|
|
|
|
|
respond_to do |format|
|
|
|
|
format.json do
|
|
|
|
query_group = QueryGroup.find_by(query_id: @query.id, group_id: @group.id)
|
|
|
|
|
|
|
|
render json: {
|
|
|
|
query: serialize_data(@query, QuerySerializer, root: nil),
|
|
|
|
query_group: serialize_data(query_group, QueryGroupSerializer, root: nil),
|
|
|
|
}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def group_reports_run
|
|
|
|
if !guardian.group_and_user_can_access_query?(@group, @query) || @query.hidden
|
|
|
|
return raise Discourse::NotFound
|
|
|
|
end
|
|
|
|
|
|
|
|
run
|
|
|
|
end
|
|
|
|
|
|
|
|
def create
|
|
|
|
query =
|
|
|
|
Query.create!(
|
|
|
|
params
|
|
|
|
.require(:query)
|
|
|
|
.permit(:name, :description, :sql)
|
|
|
|
.merge(user_id: current_user.id, last_run_at: Time.now),
|
|
|
|
)
|
|
|
|
group_ids = params.require(:query)[:group_ids]
|
|
|
|
group_ids&.each { |group_id| query.query_groups.find_or_create_by!(group_id: group_id) }
|
|
|
|
render_serialized query, QuerySerializer, root: "query"
|
|
|
|
end
|
|
|
|
|
|
|
|
def update
|
|
|
|
ActiveRecord::Base.transaction do
|
|
|
|
@query.update!(
|
|
|
|
params.require(:query).permit(:name, :sql, :description).merge(hidden: false),
|
|
|
|
)
|
|
|
|
|
|
|
|
group_ids = params.require(:query)[:group_ids]
|
|
|
|
QueryGroup.where.not(group_id: group_ids).where(query_id: @query.id).delete_all
|
|
|
|
group_ids&.each { |group_id| @query.query_groups.find_or_create_by!(group_id: group_id) }
|
|
|
|
end
|
|
|
|
|
|
|
|
render_serialized @query, QuerySerializer, root: "query"
|
|
|
|
rescue ValidationError => e
|
|
|
|
render_json_error e.message
|
|
|
|
end
|
|
|
|
|
|
|
|
def destroy
|
|
|
|
query = Query.where(id: params[:id]).first_or_initialize
|
|
|
|
query.update!(hidden: true)
|
|
|
|
|
|
|
|
render json: { success: true, errors: [] }
|
|
|
|
end
|
|
|
|
|
|
|
|
def schema
|
|
|
|
schema_version = DB.query_single("SELECT max(version) AS tag FROM schema_migrations").first
|
|
|
|
if stale?(public: true, etag: schema_version, template: false)
|
|
|
|
render json: DataExplorer.schema
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Return value:
|
|
|
|
# success - true/false. if false, inspect the errors value.
|
|
|
|
# errors - array of strings.
|
|
|
|
# params - hash. Echo of the query parameters as executed.
|
|
|
|
# duration - float. Time to execute the query, in milliseconds, to 1 decimal place.
|
|
|
|
# columns - array of strings. Titles of the returned columns, in order.
|
|
|
|
# explain - string. (Optional - pass explain=true in the request) Postgres query plan, UNIX newlines.
|
|
|
|
# rows - array of array of strings. Results of the query. In the same order as 'columns'.
|
|
|
|
def run
|
2023-04-03 01:46:35 -04:00
|
|
|
rate_limit_query_runs!
|
|
|
|
|
2023-03-22 17:29:08 -04:00
|
|
|
check_xhr unless params[:download]
|
|
|
|
|
|
|
|
query = Query.find(params[:id].to_i)
|
|
|
|
query.update!(last_run_at: Time.now)
|
|
|
|
|
|
|
|
response.sending_file = true if params[:download]
|
|
|
|
|
|
|
|
query_params = {}
|
|
|
|
query_params = MultiJson.load(params[:params]) if params[:params]
|
|
|
|
|
|
|
|
opts = { current_user: current_user.username }
|
|
|
|
opts[:explain] = true if params[:explain] == "true"
|
|
|
|
|
|
|
|
opts[:limit] = if params[:format] == "csv"
|
|
|
|
if params[:limit].present?
|
|
|
|
limit = params[:limit].to_i
|
|
|
|
limit = QUERY_RESULT_MAX_LIMIT if limit > QUERY_RESULT_MAX_LIMIT
|
|
|
|
limit
|
|
|
|
else
|
|
|
|
QUERY_RESULT_MAX_LIMIT
|
|
|
|
end
|
|
|
|
elsif params[:limit].present?
|
|
|
|
params[:limit] == "ALL" ? "ALL" : params[:limit].to_i
|
|
|
|
end
|
|
|
|
|
|
|
|
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}:", "")
|
|
|
|
else
|
|
|
|
err_msg = "#{err_class}: #{err_msg}"
|
|
|
|
end
|
|
|
|
|
|
|
|
render json: { success: false, errors: [err_msg] }, status: 422
|
|
|
|
else
|
|
|
|
pg_result = result[:pg_result]
|
|
|
|
cols = pg_result.fields
|
|
|
|
respond_to do |format|
|
|
|
|
format.json do
|
|
|
|
if params[:download]
|
|
|
|
response.headers[
|
|
|
|
"Content-Disposition"
|
|
|
|
] = "attachment; filename=#{query.slug}@#{Slug.for(Discourse.current_hostname, "discourse")}-#{Date.today}.dcqresult.json"
|
|
|
|
end
|
|
|
|
json = {
|
|
|
|
success: true,
|
|
|
|
errors: [],
|
|
|
|
duration: (result[:duration_secs].to_f * 1000).round(1),
|
|
|
|
result_count: pg_result.values.length || 0,
|
|
|
|
params: query_params,
|
|
|
|
columns: cols,
|
|
|
|
default_limit: SiteSetting.data_explorer_query_result_limit,
|
|
|
|
}
|
|
|
|
json[:explain] = result[:explain] if opts[:explain]
|
|
|
|
|
|
|
|
if !params[:download]
|
|
|
|
relations, colrender = DataExplorer.add_extra_data(pg_result)
|
|
|
|
json[:relations] = relations
|
|
|
|
json[:colrender] = colrender
|
|
|
|
end
|
|
|
|
|
|
|
|
json[:rows] = pg_result.values
|
|
|
|
|
|
|
|
render json: json
|
|
|
|
end
|
|
|
|
format.csv do
|
|
|
|
response.headers[
|
|
|
|
"Content-Disposition"
|
|
|
|
] = "attachment; filename=#{query.slug}@#{Slug.for(Discourse.current_hostname, "discourse")}-#{Date.today}.dcqresult.csv"
|
|
|
|
|
|
|
|
require "csv"
|
|
|
|
text =
|
|
|
|
CSV.generate do |csv|
|
|
|
|
csv << cols
|
|
|
|
pg_result.values.each { |row| csv << row }
|
|
|
|
end
|
|
|
|
|
|
|
|
render plain: text
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
2023-04-03 01:46:35 -04:00
|
|
|
def rate_limit_query_runs!
|
|
|
|
return if !is_api? && !is_user_api?
|
|
|
|
|
|
|
|
RateLimiter.new(
|
|
|
|
nil,
|
|
|
|
"api-query-run-10-sec",
|
|
|
|
GlobalSetting.max_data_explorer_api_reqs_per_10_seconds,
|
|
|
|
10.seconds,
|
|
|
|
).performed!
|
|
|
|
rescue RateLimiter::LimitExceeded => e
|
|
|
|
if GlobalSetting.max_data_explorer_api_req_mode.include?("warn")
|
|
|
|
Discourse.warn("Query run 10 second rate limit exceeded", query_id: params[:id])
|
|
|
|
end
|
|
|
|
raise e if GlobalSetting.max_data_explorer_api_req_mode.include?("block")
|
|
|
|
end
|
|
|
|
|
2023-03-22 17:29:08 -04:00
|
|
|
def set_group
|
|
|
|
@group = Group.find_by(name: params["group_name"])
|
|
|
|
end
|
|
|
|
|
|
|
|
def set_query
|
|
|
|
@query = Query.find(params[:id])
|
|
|
|
raise Discourse::NotFound unless @query
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|