1287 lines
42 KiB
Ruby
1287 lines
42 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# name: discourse-data-explorer
|
|
# about: Interface for running analysis SQL queries on the live database
|
|
# version: 0.2
|
|
# authors: Riking
|
|
# url: https://github.com/discourse/discourse-data-explorer
|
|
|
|
enabled_site_setting :data_explorer_enabled
|
|
register_asset 'stylesheets/explorer.scss'
|
|
|
|
if respond_to?(:register_svg_icon)
|
|
register_svg_icon "caret-down"
|
|
register_svg_icon "caret-right"
|
|
register_svg_icon "chevron-left"
|
|
register_svg_icon "exclamation-circle"
|
|
register_svg_icon "info"
|
|
register_svg_icon "pencil-alt"
|
|
register_svg_icon "upload"
|
|
end
|
|
|
|
# route: /admin/plugins/explorer
|
|
add_admin_route 'explorer.title', 'explorer'
|
|
|
|
module ::DataExplorer
|
|
QUERY_RESULT_MAX_LIMIT = 1000
|
|
|
|
def self.plugin_name
|
|
'discourse-data-explorer'.freeze
|
|
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
|
|
|
|
after_initialize do
|
|
|
|
add_to_class(:guardian, :user_is_a_member_of_group?) do |group|
|
|
return false if !current_user
|
|
return true if current_user.admin?
|
|
return current_user.group_ids.include?(group.id)
|
|
end
|
|
|
|
add_to_class(:guardian, :user_can_access_query?) do |group, query|
|
|
return false if !current_user
|
|
return true if current_user.admin?
|
|
return user_is_a_member_of_group?(group) &&
|
|
query.group_ids.include?(group.id.to_s)
|
|
end
|
|
|
|
module ::DataExplorer
|
|
class Engine < ::Rails::Engine
|
|
engine_name "data_explorer"
|
|
isolate_namespace DataExplorer
|
|
end
|
|
|
|
class ValidationError < StandardError
|
|
end
|
|
|
|
class SmallBadgeSerializer < ApplicationSerializer
|
|
attributes :id, :name, :badge_type, :description, :icon
|
|
end
|
|
|
|
class SmallPostWithExcerptSerializer < ApplicationSerializer
|
|
attributes :id, :topic_id, :post_number, :excerpt
|
|
attributes :username, :avatar_template
|
|
def excerpt
|
|
Post.excerpt(object.cooked, 70)
|
|
end
|
|
def username
|
|
object.user && object.user.username
|
|
end
|
|
def avatar_template
|
|
object.user && object.user.avatar_template
|
|
end
|
|
end
|
|
|
|
# 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, req_params = {}, opts = {})
|
|
# Safety checks
|
|
# see test 'doesn't allow you to modify the database #2'
|
|
if query.sql =~ /;/
|
|
err = DataExplorer::ValidationError.new(I18n.t('js.errors.explorer.no_semicolons'))
|
|
return { error: err, duration_nanos: 0 }
|
|
end
|
|
|
|
query_args = {}
|
|
begin
|
|
query_args = query.cast_params req_params
|
|
rescue DataExplorer::ValidationError => e
|
|
return { error: e, duration_nanos: 0 }
|
|
end
|
|
|
|
time_start, time_end, explain, err, result = nil
|
|
begin
|
|
ActiveRecord::Base.connection.transaction do
|
|
# Setting transaction to read only prevents shoot-in-foot actions like SELECT FOR UPDATE
|
|
# see test 'doesn't allow you to modify the database #1'
|
|
DB.exec "SET TRANSACTION READ ONLY"
|
|
# Set a statement timeout so we can't tie up the server
|
|
DB.exec "SET LOCAL statement_timeout = 10000"
|
|
|
|
# SQL comments are for the benefits of the slow queries log
|
|
sql = <<-SQL
|
|
/*
|
|
* DataExplorer Query
|
|
* Query: /admin/plugins/explorer?id=#{query.id}
|
|
* Started by: #{opts[:current_user]}
|
|
*/
|
|
WITH query AS (
|
|
#{query.sql}
|
|
) SELECT * FROM query
|
|
LIMIT #{opts[:limit] || DataExplorer::QUERY_RESULT_MAX_LIMIT}
|
|
SQL
|
|
|
|
time_start = Time.now
|
|
|
|
# we probably want to rewrite this ... but for now reuse the working
|
|
# code we have
|
|
sql = DB.param_encoder.encode(sql, query_args)
|
|
|
|
result = ActiveRecord::Base.connection.raw_connection.async_exec(sql)
|
|
result.check # make sure it's done
|
|
time_end = Time.now
|
|
|
|
if opts[:explain]
|
|
explain = DB.query_hash("EXPLAIN #{query.sql}", query_args)
|
|
.map { |row| row["QUERY PLAN"] }.join "\n"
|
|
end
|
|
|
|
# All done. Issue a rollback anyways, just in case
|
|
# see test 'doesn't allow you to modify the database #1'
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
rescue Exception => ex
|
|
err = ex
|
|
time_end = Time.now
|
|
end
|
|
|
|
{
|
|
error: err,
|
|
pg_result: result,
|
|
duration_secs: time_end - time_start,
|
|
explain: explain,
|
|
params_full: query_args
|
|
}
|
|
end
|
|
|
|
def self.extra_data_pluck_fields
|
|
@extra_data_pluck_fields ||= {
|
|
user: { class: User, fields: [:id, :username, :uploaded_avatar_id], serializer: BasicUserSerializer },
|
|
badge: { class: Badge, fields: [:id, :name, :badge_type_id, :description, :icon], include: [:badge_type], serializer: SmallBadgeSerializer },
|
|
post: { class: Post, fields: [:id, :topic_id, :post_number, :cooked, :user_id], include: [:user], serializer: SmallPostWithExcerptSerializer },
|
|
topic: { class: Topic, fields: [:id, :title, :slug, :posts_count], serializer: BasicTopicSerializer },
|
|
group: { class: Group, ignore: true },
|
|
category: { class: Category, ignore: true },
|
|
reltime: { ignore: true },
|
|
html: { ignore: true },
|
|
}
|
|
end
|
|
|
|
def self.column_regexes
|
|
@column_regexes ||=
|
|
extra_data_pluck_fields.map do |key, val|
|
|
if val[:class]
|
|
/(#{val[:class].to_s.downcase})_id$/
|
|
end
|
|
end.compact
|
|
end
|
|
|
|
def self.add_extra_data(pg_result)
|
|
needed_classes = {}
|
|
ret = {}
|
|
col_map = {}
|
|
|
|
pg_result.fields.each_with_index do |col, idx|
|
|
rgx = column_regexes.find { |r| r.match col }
|
|
if rgx
|
|
cls = (rgx.match col)[1].to_sym
|
|
needed_classes[cls] ||= []
|
|
needed_classes[cls] << idx
|
|
elsif col =~ /^(\w+)\$/
|
|
cls = $1.to_sym
|
|
needed_classes[cls] ||= []
|
|
needed_classes[cls] << idx
|
|
elsif col =~ /^\w+_url$/
|
|
col_map[idx] = "url"
|
|
end
|
|
end
|
|
|
|
needed_classes.each do |cls, column_nums|
|
|
next unless column_nums.present?
|
|
support_info = extra_data_pluck_fields[cls]
|
|
next unless support_info
|
|
|
|
column_nums.each do |col_n|
|
|
col_map[col_n] = cls
|
|
end
|
|
|
|
if support_info[:ignore]
|
|
ret[cls] = []
|
|
next
|
|
end
|
|
|
|
ids = Set.new
|
|
column_nums.each do |col_n|
|
|
ids.merge(pg_result.column_values(col_n))
|
|
end
|
|
ids.delete nil
|
|
ids.map! &:to_i
|
|
|
|
object_class = support_info[:class]
|
|
all_objs = object_class
|
|
all_objs = all_objs.with_deleted if all_objs.respond_to? :with_deleted
|
|
all_objs = all_objs
|
|
.select(support_info[:fields])
|
|
.where(id: ids.to_a.sort)
|
|
.includes(support_info[:include])
|
|
.order(:id)
|
|
|
|
ret[cls] = ActiveModel::ArraySerializer.new(all_objs, each_serializer: support_info[:serializer])
|
|
end
|
|
[ret, col_map]
|
|
end
|
|
|
|
def self.sensitive_column_names
|
|
%w(
|
|
#_IP_Addresses
|
|
topic_views.ip_address
|
|
users.ip_address
|
|
users.registration_ip_address
|
|
incoming_links.ip_address
|
|
topic_link_clicks.ip_address
|
|
user_histories.ip_address
|
|
|
|
#_Emails
|
|
email_tokens.email
|
|
users.email
|
|
invites.email
|
|
user_histories.email
|
|
email_logs.to_address
|
|
posts.raw_email
|
|
badge_posts.raw_email
|
|
|
|
#_Secret_Tokens
|
|
email_tokens.token
|
|
email_logs.reply_key
|
|
api_keys.key
|
|
site_settings.value
|
|
|
|
users.auth_token
|
|
users.password_hash
|
|
users.salt
|
|
|
|
#_Authentication_Info
|
|
user_open_ids.email
|
|
oauth2_user_infos.uid
|
|
oauth2_user_infos.email
|
|
facebook_user_infos.facebook_user_id
|
|
facebook_user_infos.email
|
|
twitter_user_infos.twitter_user_id
|
|
github_user_infos.github_user_id
|
|
single_sign_on_records.external_email
|
|
single_sign_on_records.external_id
|
|
google_user_infos.google_user_id
|
|
google_user_infos.email
|
|
)
|
|
end
|
|
|
|
def self.schema
|
|
# No need to expire this, because the server processes get restarted on upgrade
|
|
# refer user to http://www.postgresql.org/docs/9.3/static/datatype.html
|
|
@schema ||= begin
|
|
results = DB.query_hash <<~SQL
|
|
select
|
|
c.column_name column_name,
|
|
c.data_type data_type,
|
|
c.character_maximum_length character_maximum_length,
|
|
c.is_nullable is_nullable,
|
|
c.column_default column_default,
|
|
c.table_name table_name,
|
|
pgd.description column_desc
|
|
from INFORMATION_SCHEMA.COLUMNS c
|
|
inner join pg_catalog.pg_statio_all_tables st on (c.table_schema = st.schemaname and c.table_name = st.relname)
|
|
left outer join pg_catalog.pg_description pgd on (pgd.objoid = st.relid and pgd.objsubid = c.ordinal_position)
|
|
where c.table_schema = 'public'
|
|
ORDER BY c.table_name, c.ordinal_position
|
|
SQL
|
|
|
|
by_table = {}
|
|
# Massage the results into a nicer form
|
|
results.each do |hash|
|
|
full_col_name = "#{hash['table_name']}.#{hash['column_name']}"
|
|
|
|
if hash['is_nullable'] == "YES"
|
|
hash['is_nullable'] = true
|
|
else
|
|
hash.delete('is_nullable')
|
|
end
|
|
clen = hash.delete 'character_maximum_length'
|
|
dt = hash['data_type']
|
|
if hash['column_name'] == 'id'
|
|
hash['data_type'] = 'serial'
|
|
hash['primary'] = true
|
|
elsif dt == 'character varying'
|
|
hash['data_type'] = "varchar(#{clen.to_i})"
|
|
elsif dt == 'timestamp without time zone'
|
|
hash['data_type'] = 'timestamp'
|
|
elsif dt == 'double precision'
|
|
hash['data_type'] = 'double'
|
|
end
|
|
default = hash['column_default']
|
|
if default.nil? || default =~ /^nextval\(/
|
|
hash.delete 'column_default'
|
|
elsif default =~ /^'(.*)'::(character varying|text)/
|
|
hash['column_default'] = $1
|
|
end
|
|
hash.delete('column_desc') unless hash['column_desc']
|
|
|
|
if sensitive_column_names.include? full_col_name
|
|
hash['sensitive'] = true
|
|
end
|
|
if enum_info.include? full_col_name
|
|
hash['enum'] = enum_info[full_col_name]
|
|
end
|
|
if denormalized_columns.include? full_col_name
|
|
hash['denormal'] = denormalized_columns[full_col_name]
|
|
end
|
|
fkey = fkey_info(hash['table_name'], hash['column_name'])
|
|
if fkey
|
|
hash['fkey_info'] = fkey
|
|
end
|
|
|
|
table_name = hash.delete('table_name')
|
|
by_table[table_name] ||= []
|
|
by_table[table_name] << hash
|
|
end
|
|
|
|
# this works for now, but no big loss if the tables aren't quite sorted
|
|
favored_order = %w(posts topics users categories badges groups notifications post_actions site_settings)
|
|
sorted_by_table = {}
|
|
favored_order.each do |tbl|
|
|
sorted_by_table[tbl] = by_table[tbl]
|
|
end
|
|
by_table.keys.sort.each do |tbl|
|
|
next if favored_order.include? tbl
|
|
sorted_by_table[tbl] = by_table[tbl]
|
|
end
|
|
sorted_by_table
|
|
end
|
|
end
|
|
|
|
def self.enums
|
|
return @enums if @enums
|
|
|
|
@enums = {
|
|
'application_requests.req_type': ApplicationRequest.req_types,
|
|
'badges.badge_type_id': Enum.new(:gold, :silver, :bronze, start: 1),
|
|
'category_groups.permission_type': CategoryGroup.permission_types,
|
|
'category_users.notification_level': CategoryUser.notification_levels,
|
|
'directory_items.period_type': DirectoryItem.period_types,
|
|
'groups.id': Group::AUTO_GROUPS,
|
|
'groups.mentionable_level': Group::ALIAS_LEVELS,
|
|
'groups.messageable_level': Group::ALIAS_LEVELS,
|
|
'groups.members_visibility_level': Group.visibility_levels,
|
|
'groups.visibility_level': Group.visibility_levels,
|
|
'groups.default_notification_level': GroupUser.notification_levels,
|
|
'group_users.notification_level': GroupUser.notification_levels,
|
|
'notifications.notification_type': Notification.types,
|
|
'polls.results': Poll.results,
|
|
'polls.status': Poll.statuses,
|
|
'polls.type': Poll.types,
|
|
'polls.visibility': Poll.visibilities,
|
|
'post_action_types.id': PostActionType.types,
|
|
'post_actions.post_action_type_id': PostActionType.types,
|
|
'posts.cook_method': Post.cook_methods,
|
|
'posts.hidden_reason_id': Post.hidden_reasons,
|
|
'posts.post_type': Post.types,
|
|
'reviewable_histories.reviewable_history_type': ReviewableHistory.types,
|
|
'reviewable_scores.status': ReviewableScore.statuses,
|
|
'screened_emails.action_type': ScreenedEmail.actions,
|
|
'screened_ip_addresses.action_type': ScreenedIpAddress.actions,
|
|
'screened_urls.action_type': ScreenedUrl.actions,
|
|
'search_logs.search_result_type': SearchLog.search_result_types,
|
|
'search_logs.search_type': SearchLog.search_types,
|
|
'site_settings.data_type': SiteSetting.types,
|
|
'skipped_email_logs.reason_type': SkippedEmailLog.reason_types,
|
|
'tag_group_permissions.permission_type': TagGroupPermission.permission_types,
|
|
'theme_settings.data_type': ThemeSetting.types,
|
|
'topic_timers.status_type': TopicTimer.types,
|
|
'topic_users.notification_level': TopicUser.notification_levels,
|
|
'topic_users.notifications_reason_id': TopicUser.notification_reasons,
|
|
'user_histories.action': UserHistory.actions,
|
|
'user_security_keys.factor_type': UserSecurityKey.factor_types,
|
|
'users.trust_level': TrustLevel.levels,
|
|
'web_hooks.content_type': WebHook.content_types,
|
|
'web_hooks.last_delivery_status': WebHook.last_delivery_statuses,
|
|
}.with_indifferent_access
|
|
|
|
# QueuedPost is removed in recent Discourse releases
|
|
@enums['queued_posts.state'] = QueuedPost.states if defined?(QueuedPost)
|
|
@enums['reviewables.status'] = Reviewable.statuses if defined?(Reviewable)
|
|
|
|
@enums
|
|
end
|
|
|
|
def self.enum_info
|
|
@enum_info ||= begin
|
|
enum_info = {}
|
|
enums.map do |key, enum|
|
|
# https://stackoverflow.com/questions/10874356/reverse-a-hash-in-ruby
|
|
enum_info[key] = Hash[enum.to_a.map(&:reverse)]
|
|
end
|
|
enum_info
|
|
end
|
|
end
|
|
|
|
def self.fkey_info(table, column)
|
|
full_name = "#{table}.#{column}"
|
|
|
|
if fkey_defaults[column]
|
|
fkey_defaults[column]
|
|
elsif column =~ /_by_id$/ || column =~ /_user_id$/
|
|
:users
|
|
elsif foreign_keys[full_name]
|
|
foreign_keys[full_name]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def self.foreign_keys
|
|
@fkey_columns ||= {
|
|
'posts.last_editor_id': :users,
|
|
'posts.version': :'post_revisions.number',
|
|
|
|
'topics.featured_user1_id': :users,
|
|
'topics.featured_user2_id': :users,
|
|
'topics.featured_user3_id': :users,
|
|
'topics.featured_user4_id': :users,
|
|
'topics.featured_user5_id': :users,
|
|
|
|
'users.seen_notification_id': :notifications,
|
|
'users.uploaded_avatar_id': :uploads,
|
|
'users.primary_group_id': :groups,
|
|
|
|
'categories.latest_post_id': :posts,
|
|
'categories.latest_topic_id': :topics,
|
|
'categories.parent_category_id': :categories,
|
|
|
|
'badges.badge_grouping_id': :badge_groupings,
|
|
|
|
'post_actions.related_post_id': :posts,
|
|
|
|
'color_scheme_colors.color_scheme_id': :color_schemes,
|
|
'color_schemes.versioned_id': :color_schemes,
|
|
|
|
'incoming_links.incoming_referer_id': :incoming_referers,
|
|
'incoming_referers.incoming_domain_id': :incoming_domains,
|
|
|
|
'post_replies.reply_id': :posts,
|
|
|
|
'quoted_posts.quoted_post_id': :posts,
|
|
|
|
'topic_link_clicks.topic_link_id': :topic_links,
|
|
'topic_link_clicks.link_topic_id': :topics,
|
|
'topic_link_clicks.link_post_id': :posts,
|
|
|
|
'user_actions.target_topic_id': :topics,
|
|
'user_actions.target_post_id': :posts,
|
|
|
|
'user_avatars.custom_upload_id': :uploads,
|
|
'user_avatars.gravatar_upload_id': :uploads,
|
|
|
|
'user_badges.notification_id': :notifications,
|
|
|
|
'user_profiles.card_image_badge_id': :badges,
|
|
}.with_indifferent_access
|
|
end
|
|
|
|
def self.fkey_defaults
|
|
@fkey_defaults ||= {
|
|
user_id: :users,
|
|
# :*_by_id => :users,
|
|
# :*_user_id => :users,
|
|
|
|
category_id: :categories,
|
|
group_id: :groups,
|
|
post_id: :posts,
|
|
post_action_id: :post_actions,
|
|
topic_id: :topics,
|
|
upload_id: :uploads,
|
|
|
|
}.with_indifferent_access
|
|
end
|
|
|
|
def self.denormalized_columns
|
|
{
|
|
'posts.reply_count': :post_replies,
|
|
'posts.quote_count': :quoted_posts,
|
|
'posts.incoming_link_count': :topic_links,
|
|
'posts.word_count': :posts,
|
|
'posts.avg_time': :post_timings,
|
|
'posts.reads': :post_timings,
|
|
'posts.like_score': :post_actions,
|
|
|
|
'posts.like_count': :post_actions,
|
|
'posts.bookmark_count': :post_actions,
|
|
'posts.vote_count': :post_actions,
|
|
'posts.off_topic_count': :post_actions,
|
|
'posts.notify_moderators_count': :post_actions,
|
|
'posts.spam_count': :post_actions,
|
|
'posts.illegal_count': :post_actions,
|
|
'posts.inappropriate_count': :post_actions,
|
|
'posts.notify_user_count': :post_actions,
|
|
|
|
'topics.views': :topic_views,
|
|
'topics.posts_count': :posts,
|
|
'topics.reply_count': :posts,
|
|
'topics.incoming_link_count': :topic_links,
|
|
'topics.moderator_posts_count': :posts,
|
|
'topics.participant_count': :posts,
|
|
'topics.word_count': :posts,
|
|
'topics.last_posted_at': :posts,
|
|
'topics.last_post_user_idt': :posts,
|
|
'topics.avg_time': :post_timings,
|
|
'topics.highest_post_number': :posts,
|
|
'topics.image_url': :posts,
|
|
'topics.excerpt': :posts,
|
|
|
|
'topics.like_count': :post_actions,
|
|
'topics.bookmark_count': :post_actions,
|
|
'topics.vote_count': :post_actions,
|
|
'topics.off_topic_count': :post_actions,
|
|
'topics.notify_moderators_count': :post_actions,
|
|
'topics.spam_count': :post_actions,
|
|
'topics.illegal_count': :post_actions,
|
|
'topics.inappropriate_count': :post_actions,
|
|
'topics.notify_user_count': :post_actions,
|
|
|
|
'categories.topic_count': :topics,
|
|
'categories.post_count': :posts,
|
|
'categories.latest_post_id': :posts,
|
|
'categories.latest_topic_id': :topics,
|
|
'categories.description': :posts,
|
|
'categories.read_restricted': :category_groups,
|
|
'categories.topics_year': :topics,
|
|
'categories.topics_month': :topics,
|
|
'categories.topics_week': :topics,
|
|
'categories.topics_day': :topics,
|
|
'categories.posts_year': :posts,
|
|
'categories.posts_month': :posts,
|
|
'categories.posts_week': :posts,
|
|
'categories.posts_day': :posts,
|
|
|
|
'badges.grant_count': :user_badges,
|
|
'groups.user_count': :group_users,
|
|
|
|
'directory_items.likes_received': :post_actions,
|
|
'directory_items.likes_given': :post_actions,
|
|
'directory_items.topics_entered': :user_stats,
|
|
'directory_items.days_visited': :user_stats,
|
|
'directory_items.posts_read': :user_stats,
|
|
'directory_items.topic_count': :topics,
|
|
'directory_items.post_count': :posts,
|
|
|
|
'post_search_data.search_data': :posts,
|
|
|
|
'top_topics.yearly_posts_count': :posts,
|
|
'top_topics.monthly_posts_count': :posts,
|
|
'top_topics.weekly_posts_count': :posts,
|
|
'top_topics.daily_posts_count': :posts,
|
|
'top_topics.yearly_views_count': :topic_views,
|
|
'top_topics.monthly_views_count': :topic_views,
|
|
'top_topics.weekly_views_count': :topic_views,
|
|
'top_topics.daily_views_count': :topic_views,
|
|
'top_topics.yearly_likes_count': :post_actions,
|
|
'top_topics.monthly_likes_count': :post_actions,
|
|
'top_topics.weekly_likes_count': :post_actions,
|
|
'top_topics.daily_likes_count': :post_actions,
|
|
'top_topics.yearly_op_likes_count': :post_actions,
|
|
'top_topics.monthly_op_likes_count': :post_actions,
|
|
'top_topics.weekly_op_likes_count': :post_actions,
|
|
'top_topics.daily_op_likes_count': :post_actions,
|
|
'top_topics.all_score': :posts,
|
|
'top_topics.yearly_score': :posts,
|
|
'top_topics.monthly_score': :posts,
|
|
'top_topics.weekly_score': :posts,
|
|
'top_topics.daily_score': :posts,
|
|
|
|
'topic_links.clicks': :topic_link_clicks,
|
|
'topic_search_data.search_data': :topics,
|
|
|
|
'topic_users.liked': :post_actions,
|
|
'topic_users.bookmarked': :post_actions,
|
|
|
|
'user_stats.posts_read_count': :post_timings,
|
|
'user_stats.topic_reply_count': :posts,
|
|
'user_stats.first_post_created_at': :posts,
|
|
'user_stats.post_count': :posts,
|
|
'user_stats.topic_count': :topics,
|
|
'user_stats.likes_given': :post_actions,
|
|
'user_stats.likes_received': :post_actions,
|
|
|
|
'user_search_data.search_data': :user_profiles,
|
|
|
|
'users.last_posted_at': :posts,
|
|
'users.previous_visit_at': :user_visits,
|
|
}.with_indifferent_access
|
|
end
|
|
end
|
|
|
|
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
|
|
require_dependency File.expand_path('../lib/queries.rb', __FILE__)
|
|
class DataExplorer::Query
|
|
attr_accessor :id, :name, :description, :sql, :created_by, :created_at, :group_ids, :last_run_at
|
|
|
|
def initialize
|
|
@name = 'Unnamed Query'
|
|
@description = ''
|
|
@sql = 'SELECT 1'
|
|
@group_ids = []
|
|
end
|
|
|
|
def slug
|
|
Slug.for(name).presence || "query-#{id}"
|
|
end
|
|
|
|
def params
|
|
@params ||= DataExplorer::Parameter.create_from_sql(sql)
|
|
end
|
|
|
|
def check_params!
|
|
DataExplorer::Parameter.create_from_sql(sql, strict: true)
|
|
nil
|
|
end
|
|
|
|
def cast_params(input_params)
|
|
result = {}.with_indifferent_access
|
|
self.params.each do |pobj|
|
|
result[pobj.identifier] = pobj.cast_to_ruby input_params[pobj.identifier]
|
|
end
|
|
result
|
|
end
|
|
|
|
def can_be_run_by(group)
|
|
@group_ids.include?(group.id.to_s)
|
|
end
|
|
|
|
# saving/loading functions
|
|
# May want to extract this into a library or something for plugins to use?
|
|
def self.alloc_id
|
|
DistributedMutex.synchronize('data-explorer_query-id') do
|
|
max_id = DataExplorer.pstore_get("q:_id")
|
|
max_id = 1 unless max_id
|
|
DataExplorer.pstore_set("q:_id", max_id + 1)
|
|
max_id
|
|
end
|
|
end
|
|
|
|
def self.from_hash(h)
|
|
query = DataExplorer::Query.new
|
|
[:name, :description, :sql, :created_by, :created_at, :last_run_at].each do |sym|
|
|
query.send("#{sym}=", h[sym].strip) if h[sym]
|
|
end
|
|
group_ids = (h[:group_ids] == "" || !h[:group_ids]) ? [] : h[:group_ids]
|
|
query.group_ids = group_ids
|
|
query.id = h[:id].to_i if h[:id]
|
|
query
|
|
end
|
|
|
|
def to_hash
|
|
{
|
|
id: @id,
|
|
name: @name,
|
|
description: @description,
|
|
sql: @sql,
|
|
created_by: @created_by,
|
|
created_at: @created_at,
|
|
group_ids: @group_ids,
|
|
last_run_at: @last_run_at
|
|
}
|
|
end
|
|
|
|
def self.find(id, opts = {})
|
|
if DataExplorer.pstore_get("q:#{id}").nil? && id < 0
|
|
hash = Queries.default[id.to_s]
|
|
hash[:id] = id
|
|
from_hash hash
|
|
else
|
|
unless hash = DataExplorer.pstore_get("q:#{id}")
|
|
return DataExplorer::Query.new if opts[:ignore_deleted]
|
|
raise Discourse::NotFound
|
|
end
|
|
from_hash hash
|
|
end
|
|
end
|
|
|
|
def save
|
|
check_params!
|
|
return save_default_query if @id && @id < 0
|
|
|
|
@id = @id || self.class.alloc_id
|
|
DataExplorer.pstore_set "q:#{id}", to_hash
|
|
end
|
|
|
|
def save_default_query
|
|
check_params!
|
|
# Read from queries.rb again to pick up any changes and save them
|
|
query = Queries.default[id.to_s]
|
|
@id = query["id"]
|
|
@sql = query["sql"]
|
|
@group_ids = @group_ids || []
|
|
@name = query["name"]
|
|
@description = query["description"]
|
|
|
|
DataExplorer.pstore_set "q:#{id}", to_hash
|
|
end
|
|
|
|
def destroy
|
|
DataExplorer.pstore_delete "q:#{id}"
|
|
end
|
|
|
|
def read_attribute_for_serialization(attr)
|
|
self.send(attr)
|
|
end
|
|
|
|
def self.all
|
|
PluginStoreRow.where(plugin_name: DataExplorer.plugin_name)
|
|
.where("key LIKE 'q:%'")
|
|
.where("key != 'q:_id'")
|
|
.map do |psr|
|
|
DataExplorer::Query.from_hash PluginStore.cast_value(psr.type_name, psr.value)
|
|
end.sort_by { |query| query.name }
|
|
end
|
|
|
|
def self.destroy_all
|
|
PluginStoreRow.where(plugin_name: DataExplorer.plugin_name)
|
|
.where("key LIKE 'q:%'")
|
|
.destroy_all
|
|
end
|
|
end
|
|
|
|
class DataExplorer::Parameter
|
|
attr_accessor :identifier, :type, :default, :nullable
|
|
|
|
def initialize(identifier, type, default, nullable)
|
|
raise DataExplorer::ValidationError.new('Parameter declaration error - identifier is missing') unless identifier
|
|
raise DataExplorer::ValidationError.new('Parameter declaration error - type is missing') unless type
|
|
# process aliases
|
|
type = type.to_sym
|
|
if DataExplorer::Parameter.type_aliases[type]
|
|
type = DataExplorer::Parameter.type_aliases[type]
|
|
end
|
|
raise DataExplorer::ValidationError.new("Parameter declaration error - unknown type #{type}") unless DataExplorer::Parameter.types[type]
|
|
|
|
@identifier = identifier
|
|
@type = type
|
|
@default = default
|
|
@nullable = nullable
|
|
begin
|
|
cast_to_ruby default unless default.blank?
|
|
rescue DataExplorer::ValidationError
|
|
raise DataExplorer::ValidationError.new("Parameter declaration error - the default value is not a valid #{type}")
|
|
end
|
|
end
|
|
|
|
def to_hash
|
|
{
|
|
identifier: @identifier,
|
|
type: @type,
|
|
default: @default,
|
|
nullable: @nullable,
|
|
}
|
|
end
|
|
|
|
def self.types
|
|
@types ||= Enum.new(
|
|
# Normal types
|
|
:int, :bigint, :boolean, :string, :date, :time, :datetime, :double,
|
|
# Selection help
|
|
:user_id, :post_id, :topic_id, :category_id, :group_id, :badge_id,
|
|
# Arrays
|
|
:int_list, :string_list, :user_list
|
|
)
|
|
end
|
|
|
|
def self.type_aliases
|
|
@type_aliases ||= {
|
|
integer: :int,
|
|
text: :string,
|
|
timestamp: :datetime,
|
|
}
|
|
end
|
|
|
|
def cast_to_ruby(string)
|
|
string = @default unless string
|
|
|
|
if string.blank?
|
|
if @nullable
|
|
return nil
|
|
else
|
|
raise DataExplorer::ValidationError.new("Missing parameter #{identifier} of type #{type}")
|
|
end
|
|
end
|
|
if string.downcase == '#null'
|
|
return nil
|
|
end
|
|
|
|
def invalid_format(string, msg = nil)
|
|
if msg
|
|
raise DataExplorer::ValidationError.new("'#{string}' is an invalid #{type} - #{msg}")
|
|
else
|
|
raise DataExplorer::ValidationError.new("'#{string}' is an invalid value for #{type}")
|
|
end
|
|
end
|
|
|
|
value = nil
|
|
|
|
case @type
|
|
when :int
|
|
invalid_format string, 'Not an integer' unless string =~ /^-?\d+$/
|
|
value = string.to_i
|
|
invalid_format string, 'Too large' unless Integer === value
|
|
when :bigint
|
|
invalid_format string, 'Not an integer' unless string =~ /^-?\d+$/
|
|
value = string.to_i
|
|
when :boolean
|
|
value = !!(string =~ /t|true|y|yes|1/i)
|
|
when :string
|
|
value = string
|
|
when :time
|
|
begin
|
|
value = Time.parse string
|
|
rescue ArgumentError => e
|
|
invalid_format string, e.message
|
|
end
|
|
when :date
|
|
begin
|
|
value = Date.parse string
|
|
rescue ArgumentError => e
|
|
invalid_format string, e.message
|
|
end
|
|
when :datetime
|
|
begin
|
|
value = DateTime.parse string
|
|
rescue ArgumentError => e
|
|
invalid_format string, e.message
|
|
end
|
|
when :double
|
|
if string =~ /-?\d*(\.\d+)/
|
|
value = Float(string)
|
|
elsif string =~ /^(-?)Inf(inity)?$/i
|
|
if $1
|
|
value = -Float::INFINITY
|
|
else
|
|
value = Float::INFINITY
|
|
end
|
|
elsif string =~ /^(-?)NaN$/i
|
|
if $1
|
|
value = -Float::NAN
|
|
else
|
|
value = Float::NAN
|
|
end
|
|
else
|
|
invalid_format string
|
|
end
|
|
when :category_id
|
|
if string =~ /(.*)\/(.*)/
|
|
parent_name = $1
|
|
child_name = $2
|
|
parent = Category.query_parent_category(parent_name)
|
|
invalid_format string, "Could not find category named #{parent_name}" unless parent
|
|
object = Category.query_category(child_name, parent)
|
|
invalid_format string, "Could not find subcategory of #{parent_name} named #{child_name}" unless object
|
|
else
|
|
object = Category.where(id: string.to_i).first || Category.where(slug: string).first || Category.where(name: string).first
|
|
invalid_format string, "Could not find category named #{string}" unless object
|
|
end
|
|
|
|
value = object.id
|
|
when :user_id, :post_id, :topic_id, :group_id, :badge_id
|
|
if string.gsub(/[ _]/, '') =~ /^-?\d+$/
|
|
clazz_name = (/^(.*)_id$/.match(type.to_s)[1].classify.to_sym)
|
|
begin
|
|
object = Object.const_get(clazz_name).with_deleted.find(string.gsub(/[ _]/, '').to_i)
|
|
value = object.id
|
|
rescue ActiveRecord::RecordNotFound
|
|
invalid_format string, "The specified #{clazz_name} was not found"
|
|
end
|
|
elsif type == :user_id
|
|
begin
|
|
object = User.find_by_username_or_email(string)
|
|
value = object.id
|
|
rescue ActiveRecord::RecordNotFound
|
|
invalid_format string, "The user named #{string} was not found"
|
|
end
|
|
elsif type == :post_id
|
|
if string =~ /(\d+)\/(\d+)(\?u=.*)?$/
|
|
object = Post.with_deleted.find_by(topic_id: $1, post_number: $2)
|
|
invalid_format string, "The post at topic:#{$1} post_number:#{$2} was not found" unless object
|
|
value = object.id
|
|
end
|
|
elsif type == :topic_id
|
|
if string =~ /\/t\/[^\/]+\/(\d+)/
|
|
begin
|
|
object = Topic.with_deleted.find($1)
|
|
value = object.id
|
|
rescue ActiveRecord::RecordNotFound
|
|
invalid_format string, "The topic with id #{$1} was not found"
|
|
end
|
|
end
|
|
elsif type == :group_id
|
|
object = Group.where(name: string).first
|
|
invalid_format string, "The group named #{string} was not found" unless object
|
|
value = object.id
|
|
else
|
|
invalid_format string
|
|
end
|
|
when :int_list
|
|
value = string.split(',').map { |s| s.downcase == '#null' ? nil : s.to_i }
|
|
invalid_format string, "can't be empty" if value.length == 0
|
|
when :string_list
|
|
value = string.split(',').map { |s| s.downcase == '#null' ? nil : s }
|
|
invalid_format string, "can't be empty" if value.length == 0
|
|
when :user_list
|
|
value = string.split(',').map { |s| User.find_by_username_or_email(s) }
|
|
invalid_format string, "can't be empty" if value.length == 0
|
|
else
|
|
raise TypeError.new('unknown parameter type??? should not get here')
|
|
end
|
|
|
|
value
|
|
end
|
|
|
|
def self.create_from_sql(sql, opts = {})
|
|
in_params = false
|
|
ret_params = []
|
|
sql.lines.find do |line|
|
|
line.chomp!
|
|
|
|
if in_params
|
|
# -- (ident) :(ident) (= (ident))?
|
|
|
|
if line =~ /^\s*--\s*([a-zA-Z_ ]+)\s*:([a-z_]+)\s*(?:=\s+(.*)\s*)?$/
|
|
type = $1
|
|
ident = $2
|
|
default = $3
|
|
nullable = false
|
|
if type =~ /^(null)?(.*?)(null)?$/i
|
|
if $1 || $3
|
|
nullable = true
|
|
end
|
|
type = $2
|
|
end
|
|
type = type.strip
|
|
|
|
begin
|
|
ret_params << DataExplorer::Parameter.new(ident, type, default, nullable)
|
|
rescue
|
|
if opts[:strict]
|
|
raise
|
|
end
|
|
end
|
|
|
|
false
|
|
elsif line =~ /^\s+$/
|
|
false
|
|
else
|
|
true
|
|
end
|
|
|
|
else
|
|
if line =~ /^\s*--\s*\[params\]\s*$/
|
|
in_params = true
|
|
end
|
|
false
|
|
end
|
|
end
|
|
ret_params
|
|
end
|
|
end
|
|
|
|
require_dependency 'application_controller'
|
|
require_dependency File.expand_path('../lib/queries.rb', __FILE__)
|
|
class DataExplorer::QueryController < ::ApplicationController
|
|
requires_plugin DataExplorer.plugin_name
|
|
|
|
before_action :check_enabled
|
|
before_action :set_group, only: [:group_reports_index, :group_reports_show, :group_reports_run]
|
|
before_action :set_query, only: [:group_reports_show, :group_reports_run]
|
|
|
|
attr_reader :group, :query
|
|
|
|
def check_enabled
|
|
raise Discourse::NotFound unless SiteSetting.data_explorer_enabled?
|
|
end
|
|
|
|
def set_group
|
|
@group = Group.find_by(name: params["group_name"])
|
|
end
|
|
|
|
def set_query
|
|
@query = DataExplorer::Query.find(params[:id].to_i)
|
|
end
|
|
|
|
def index
|
|
# guardian.ensure_can_use_data_explorer!
|
|
queries = DataExplorer::Query.all
|
|
Queries.default.each do |params|
|
|
query = DataExplorer::Query.new
|
|
query.id = params.second["id"]
|
|
query.sql = params.second["sql"]
|
|
query.name = params.second["name"]
|
|
query.description = params.second["description"]
|
|
query.created_by = Discourse::SYSTEM_USER_ID.to_s
|
|
|
|
# don't render this query if query with the same id already exists in pstore
|
|
queries.push(query) unless DataExplorer.pstore_get("q:#{query.id}").present?
|
|
end
|
|
|
|
render_serialized queries, DataExplorer::QuerySerializer, root: 'queries'
|
|
end
|
|
|
|
skip_before_action :check_xhr, only: [:show]
|
|
def show
|
|
check_xhr unless params[:export]
|
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
|
|
if params[:export]
|
|
response.headers['Content-Disposition'] = "attachment; filename=#{query.slug}.dcquery.json"
|
|
response.sending_file = true
|
|
end
|
|
|
|
# guardian.ensure_can_see! query
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
|
|
end
|
|
|
|
def groups
|
|
render_serialized(Group.all, BasicGroupSerializer)
|
|
end
|
|
|
|
def group_reports_index
|
|
return raise Discourse::NotFound unless guardian.user_is_a_member_of_group?(group)
|
|
|
|
respond_to do |format|
|
|
format.html { render 'groups/show' }
|
|
format.json do
|
|
queries = DataExplorer::Query.all
|
|
queries.select! { |query| query.group_ids&.include?(group.id.to_s) }
|
|
render_serialized queries, DataExplorer::QuerySerializer, root: 'queries'
|
|
end
|
|
end
|
|
end
|
|
|
|
def group_reports_show
|
|
return raise Discourse::NotFound unless guardian.user_can_access_query?(group, query)
|
|
|
|
respond_to do |format|
|
|
format.html { render 'groups/show' }
|
|
format.json do
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
|
|
end
|
|
end
|
|
end
|
|
|
|
def group_reports_run
|
|
return raise Discourse::NotFound unless guardian.user_can_access_query?(group, query)
|
|
|
|
run
|
|
end
|
|
|
|
def create
|
|
# guardian.ensure_can_create_explorer_query!
|
|
|
|
query = DataExplorer::Query.from_hash params.require(:query)
|
|
query.created_at = Time.now
|
|
query.created_by = current_user.id.to_s
|
|
query.last_run_at = Time.now
|
|
query.id = nil # json import will assign an id, which is wrong
|
|
query.save
|
|
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
|
|
end
|
|
|
|
def update
|
|
query = DataExplorer::Query.find(params[:id].to_i, ignore_deleted: true)
|
|
hash = params.require(:query)
|
|
hash[:group_ids] ||= []
|
|
|
|
# Undeleting
|
|
unless query.id
|
|
if hash[:id]
|
|
query.id = hash[:id].to_i
|
|
else
|
|
raise Discourse::NotFound
|
|
end
|
|
end
|
|
|
|
[:name, :sql, :description, :created_by, :created_at, :group_ids, :last_run_at].each do |sym|
|
|
query.send("#{sym}=", hash[sym]) if hash[sym]
|
|
end
|
|
|
|
query.check_params!
|
|
query.save
|
|
|
|
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
|
|
rescue DataExplorer::ValidationError => e
|
|
render_json_error e.message
|
|
end
|
|
|
|
def destroy
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
query.destroy
|
|
|
|
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
|
|
|
|
skip_before_action :check_xhr, only: [:run]
|
|
# 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
|
|
check_xhr unless params[:download]
|
|
|
|
query = DataExplorer::Query.find(params[:id].to_i)
|
|
query.last_run_at = Time.now
|
|
|
|
if params[:id].to_i < 0
|
|
query.created_by = Discourse::SYSTEM_USER_ID.to_s
|
|
query.save_default_query
|
|
else
|
|
query.save
|
|
end
|
|
|
|
if params[:download]
|
|
response.sending_file = true
|
|
end
|
|
|
|
params[:params] = params[:_params] if params[:_params] # testing workaround
|
|
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[:limit] == "ALL" || params[:format] == "csv"
|
|
"ALL"
|
|
elsif params[:limit]
|
|
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: DataExplorer::QUERY_RESULT_MAX_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 do |row|
|
|
csv << row
|
|
end
|
|
end
|
|
|
|
render plain: text
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
class DataExplorer::QuerySerializer < ActiveModel::Serializer
|
|
attributes :id, :sql, :name, :description, :param_info, :created_by, :created_at, :username, :group_ids, :last_run_at
|
|
|
|
def param_info
|
|
object.params.map(&:to_hash) rescue nil
|
|
end
|
|
|
|
def username
|
|
User.find(created_by).username rescue nil
|
|
end
|
|
end
|
|
|
|
DataExplorer::Engine.routes.draw do
|
|
root to: "query#index"
|
|
get 'schema' => "query#schema"
|
|
get 'queries' => "query#index"
|
|
get 'groups' => "query#groups"
|
|
post 'queries' => "query#create"
|
|
get 'queries/:id' => "query#show"
|
|
put 'queries/:id' => "query#update"
|
|
delete 'queries/:id' => "query#destroy"
|
|
post 'queries/:id/run' => "query#run"
|
|
end
|
|
|
|
Discourse::Application.routes.append do
|
|
get '/g/:group_name/reports' => 'data_explorer/query#group_reports_index'
|
|
get '/g/:group_name/reports/:id' => 'data_explorer/query#group_reports_show'
|
|
post '/g/:group_name/reports/:id/run' => 'data_explorer/query#group_reports_run'
|
|
|
|
mount ::DataExplorer::Engine, at: '/admin/plugins/explorer', constraints: AdminConstraint.new
|
|
end
|
|
end
|