discourse/lib/topic_view.rb
Sam 5f64fd0a21 DEV: remove exec_sql and replace with mini_sql
Introduce new patterns for direct sql that are safe and fast.

MiniSql is not prone to memory bloat that can happen with direct PG usage.
It also has an extremely fast materializer and very a convenient API

- DB.exec(sql, *params) => runs sql returns row count
- DB.query(sql, *params) => runs sql returns usable objects (not a hash)
- DB.query_hash(sql, *params) => runs sql returns an array of hashes
- DB.query_single(sql, *params) => runs sql and returns a flat one dimensional array
- DB.build(sql) => returns a sql builder

See more at: https://github.com/discourse/mini_sql
2018-06-19 16:13:36 +10:00

549 lines
15 KiB
Ruby

require_dependency 'guardian'
require_dependency 'topic_query'
require_dependency 'filter_best_posts'
require_dependency 'gaps'
class TopicView
attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size, :print, :message_bus_last_id
attr_accessor :draft, :draft_key, :draft_sequence, :user_custom_fields, :post_custom_fields, :post_number
def self.slow_chunk_size
10
end
def self.print_chunk_size
1000
end
def self.chunk_size
20
end
def self.default_post_custom_fields
@default_post_custom_fields ||= ["action_code_who"]
end
def self.post_custom_fields_whitelisters
@post_custom_fields_whitelisters ||= Set.new
end
def self.add_post_custom_fields_whitelister(&block)
post_custom_fields_whitelisters << block
end
def self.whitelisted_post_custom_fields(user)
wpcf = default_post_custom_fields + post_custom_fields_whitelisters.map { |w| w.call(user) }
wpcf.flatten.uniq
end
def initialize(topic_or_topic_id, user = nil, options = {})
@topic = find_topic(topic_or_topic_id)
@user = user
@guardian = Guardian.new(@user)
check_and_raise_exceptions
@message_bus_last_id = MessageBus.last_id("/topic/#{@topic.id}")
@print = options[:print].present?
options.each do |key, value|
self.instance_variable_set("@#{key}".to_sym, value)
end
@post_number = [@post_number.to_i, 1].max
@page = [@page.to_i, 1].max
@chunk_size =
case
when options[:slow_platform] then TopicView.slow_chunk_size
when @print then TopicView.print_chunk_size
else TopicView.chunk_size
end
@limit ||= @chunk_size
setup_filtered_posts
@initial_load = true
@index_reverse = false
filter_posts(options)
if @posts
if (added_fields = User.whitelisted_user_custom_fields(@guardian)).present?
@user_custom_fields = User.custom_fields_for_ids(@posts.pluck(:user_id), added_fields)
end
if (whitelisted_fields = TopicView.whitelisted_post_custom_fields(@user)).present?
@post_custom_fields = Post.custom_fields_for_ids(@posts.pluck(:id), whitelisted_fields)
end
end
@draft_key = @topic.draft_key
@draft_sequence = DraftSequence.current(@user, @draft_key)
end
def canonical_path
path = relative_url
path <<
if @page > 1
"?page=#{@page}"
else
page = ((@post_number - 1) / @limit) + 1
page > 1 ? "?page=#{page}" : ""
end
path
end
def contains_gaps?
@contains_gaps
end
def gaps
return unless @contains_gaps
Gaps.new(filtered_post_ids, unfiltered_posts.order(:sort_order).pluck(:id))
end
def last_post
return nil if @posts.blank?
@last_post ||= @posts.last
end
def prev_page
@page > 1 && posts.size > 0 ? @page - 1 : nil
end
def next_page
@next_page ||= begin
if last_post && (@topic.highest_post_number > last_post.post_number)
@page + 1
end
end
end
def prev_page_path
if prev_page > 1
"#{relative_url}?page=#{prev_page}"
else
relative_url
end
end
def next_page_path
"#{relative_url}?page=#{next_page}"
end
def absolute_url
"#{Discourse.base_url}#{relative_url}"
end
def relative_url
"#{@topic.relative_url}#{@print ? '/print' : ''}"
end
def page_title
title = @topic.title
if SiteSetting.topic_page_title_includes_category
if @topic.category_id != SiteSetting.uncategorized_category_id && @topic.category_id && @topic.category
title += " - #{@topic.category.name}"
elsif SiteSetting.tagging_enabled && @topic.tags.exists?
title += " - #{@topic.tags.order('tags.topic_count DESC').first.name}"
end
end
title
end
def title
@topic.title
end
def desired_post
return @desired_post if @desired_post.present?
return nil if posts.blank?
@desired_post = posts.detect { |p| p.post_number == @post_number }
@desired_post ||= posts.first
@desired_post
end
def summary(opts = {})
return nil if desired_post.blank?
# TODO, this is actually quite slow, should be cached in the post table
excerpt = desired_post.excerpt(500, opts.merge(strip_links: true, text_entities: true))
(excerpt || "").gsub(/\n/, ' ').strip
end
def read_time
return nil if @post_number > 1 # only show for topic URLs
(@topic.word_count / SiteSetting.read_time_word_count).floor if @topic.word_count
end
def like_count
return nil if @post_number > 1 # only show for topic URLs
@topic.like_count
end
def image_url
if @post_number > 1 && @desired_post.present?
if @desired_post.image_url.present?
@desired_post.image_url
elsif @desired_post.user
# show poster avatar
@desired_post.user.avatar_template_url.gsub("{size}", "200")
end
else
@topic.image_url
end
end
def filter_posts(opts = {})
return filter_posts_near(opts[:post_number].to_i) if opts[:post_number].present?
return filter_posts_by_ids(opts[:post_ids]) if opts[:post_ids].present?
return filter_best(opts[:best], opts) if opts[:best].present?
filter_posts_paged(@page)
end
def primary_group_names
return @group_names if @group_names
primary_group_ids = Set.new
@posts.each do |p|
primary_group_ids << p.user.primary_group_id if p.user.try(:primary_group_id)
end
result = {}
unless primary_group_ids.empty?
Group.where(id: primary_group_ids.to_a).pluck(:id, :name).each do |g|
result[g[0]] = g[1]
end
end
@group_names = result
end
# Find the sort order for a post in the topic
def sort_order_for_post_number(post_number)
posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted
posts = filter_post_types(posts)
posts.select(:sort_order).first.try(:sort_order)
end
# Filter to all posts near a particular post number
def filter_posts_near(post_number)
min_idx, max_idx = get_minmax_ids(post_number)
filter_posts_in_range(min_idx, max_idx)
end
def filter_posts_paged(page)
page = [page, 1].max
min = @limit * (page - 1)
# Sometimes we don't care about the OP, for example when embedding comments
min = 1 if min == 0 && @exclude_first
max = (min + @limit) - 1
filter_posts_in_range(min, max)
end
def filter_best(max, opts = {})
filter = FilterBestPosts.new(@topic, @filtered_posts, max, opts)
@posts = filter.posts
@filtered_posts = filter.filtered_posts
end
def read?(post_number)
return true unless @user
read_posts_set.include?(post_number)
end
def has_deleted?
@predelete_filtered_posts.with_deleted
.where("posts.deleted_at IS NOT NULL")
.where("posts.post_number > 1")
.exists?
end
def topic_user
@topic_user ||= begin
return nil if @user.blank?
@topic.topic_users.find_by(user_id: @user.id)
end
end
MAX_PARTICIPANTS = 24
def post_counts_by_user
@post_counts_by_user ||= begin
post_ids = unfiltered_post_ids
return {} if post_ids.blank?
sql = <<~SQL
SELECT user_id, count(*) AS count_all
FROM posts
WHERE id in (:post_ids)
AND user_id IS NOT NULL
GROUP BY user_id
ORDER BY count_all DESC
LIMIT #{MAX_PARTICIPANTS}
SQL
Hash[*DB.query_single(sql, post_ids: post_ids)]
end
end
def participant_count
@participant_count ||=
begin
if participants.size == MAX_PARTICIPANTS
sql = <<~SQL
SELECT COUNT(DISTINCT user_id)
FROM posts
WHERE id IN (:post_ids)
AND user_id IS NOT NULL
SQL
DB.query_single(sql, post_ids: unfiltered_post_ids).first.to_i
else
participants.size
end
end
end
def participants
@participants ||= begin
participants = {}
User.where(id: post_counts_by_user.keys).includes(:primary_group).each { |u| participants[u.id] = u }
participants
end
end
def all_post_actions
@all_post_actions ||= PostAction.counts_for(@posts, @user)
end
def all_active_flags
@all_active_flags ||= PostAction.active_flags_counts_for(@posts)
end
def links
@links ||= TopicLink.topic_map(@guardian, @topic.id)
end
def link_counts
@link_counts ||= TopicLink.counts_for(@guardian, @topic, posts)
end
# Are we the initial page load? If so, we can return extra information like
# user post counts, etc.
def initial_load?
@initial_load
end
def suggested_topics
@suggested_topics ||= TopicQuery.new(@user).list_suggested_for(topic)
end
# This is pending a larger refactor, that allows custom orders
# for now we need to look for the highest_post_number in the stream
# the cache on topics is not correct if there are deleted posts at
# the end of the stream (for mods), nor is it correct for filtered
# streams
def highest_post_number
@highest_post_number ||= @filtered_posts.maximum(:post_number)
end
def recent_posts
@filtered_posts.by_newest.with_user.first(25)
end
# Returns an array of [id, post_number, days_ago] tuples.
# `days_ago` is there for the timeline calculations.
def filtered_post_stream
@filtered_post_stream ||= @filtered_posts
.order(:sort_order)
.pluck(:id, :post_number, 'EXTRACT(DAYS FROM CURRENT_TIMESTAMP - created_at)::INT AS days_ago')
end
def filtered_post_ids
@filtered_post_ids ||= filtered_post_stream.map { |tuple| tuple[0] }
end
def unfiltered_post_ids
@unfiltered_post_ids ||=
begin
if @contains_gaps
unfiltered_posts.pluck(:id)
else
filtered_post_ids
end
end
end
protected
def read_posts_set
@read_posts_set ||= begin
result = Set.new
return result unless @user.present?
return result unless topic_user.present?
post_numbers = PostTiming
.where(topic_id: @topic.id, user_id: @user.id)
.where(post_number: @posts.pluck(:post_number))
.pluck(:post_number)
post_numbers.each { |pn| result << pn }
result
end
end
private
def filter_post_types(posts)
visible_types = Topic.visible_post_types(@user)
if @user.present?
posts.where("posts.user_id = ? OR post_type IN (?)", @user.id, visible_types)
else
posts.where(post_type: visible_types)
end
end
def filter_posts_by_ids(post_ids)
# TODO: Sort might be off
@posts = Post.where(id: post_ids, topic_id: @topic.id)
.includes({ user: :primary_group }, :reply_to_user, :deleted_by, :incoming_email, :topic)
.order('sort_order')
@posts = filter_post_types(@posts)
@posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
@posts
end
def filter_posts_in_range(min, max)
post_count = (filtered_post_ids.length - 1)
max = [max, post_count].min
return @posts = Post.none if min > max
min = [[min, max].min, 0].max
@posts = filter_posts_by_ids(filtered_post_ids[min..max])
@posts
end
def find_topic(topic_or_topic_id)
if topic_or_topic_id.is_a?(Topic)
topic_or_topic_id
else
# with_deleted covered in #check_and_raise_exceptions
finder = Topic.with_deleted.where(id: topic_or_topic_id).includes(:category)
finder.first
end
end
def unfiltered_posts
result = filter_post_types(@topic.posts)
result = result.with_deleted if @guardian.can_see_deleted_posts?
result = result.where("user_id IS NOT NULL") if @exclude_deleted_users
result = result.where(hidden: false) if @exclude_hidden
result
end
def setup_filtered_posts
# Certain filters might leave gaps between posts. If that's true, we can return a gap structure
@contains_gaps = false
@filtered_posts = unfiltered_posts
# Filters
if @filter == 'summary'
@filtered_posts = @filtered_posts.summary(@topic.id)
@contains_gaps = true
end
if @best.present?
@filtered_posts = @filtered_posts.where('posts.post_type = ?', Post.types[:regular])
@contains_gaps = true
end
# Username filters
if @username_filters.present?
usernames = @username_filters.map { |u| u.downcase }
@filtered_posts = @filtered_posts.where('post_number = 1 OR posts.user_id IN (SELECT u.id FROM users u WHERE username_lower IN (?))', usernames)
@contains_gaps = true
end
# Deleted
# This should be last - don't want to tell the admin about deleted posts that clicking the button won't show
# copy the filter for has_deleted? method
@predelete_filtered_posts = @filtered_posts.spawn
if @guardian.can_see_deleted_posts? && !@show_deleted && has_deleted?
@filtered_posts = @filtered_posts.where("deleted_at IS NULL OR post_number = 1")
@contains_gaps = true
end
end
def check_and_raise_exceptions
raise Discourse::NotFound if @topic.blank?
# Special case: If the topic is private and the user isn't logged in, ask them
# to log in!
if @topic.present? && @topic.private_message? && @user.blank?
raise Discourse::NotLoggedIn.new
end
# can user see this topic?
raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless @guardian.can_see?(@topic)
# log personal message views
if SiteSetting.log_personal_messages_views && @topic.present? && @topic.private_message? && @topic.all_allowed_users.where(id: @user.id).blank?
unless UserHistory.where(acting_user_id: @user.id, action: UserHistory.actions[:check_personal_message], topic_id: @topic.id).where("created_at > ?", 1.hour.ago).exists?
StaffActionLogger.new(@user).log_check_personal_message(@topic)
end
end
end
def get_minmax_ids(post_number)
# Find the closest number we have
closest_index = closest_post_to(post_number)
return nil if closest_index.nil?
# Make sure to get at least one post before, even with rounding
posts_before = (@limit.to_f / 4).floor
posts_before = 1 if posts_before.zero?
min_idx = closest_index - posts_before
min_idx = 0 if min_idx < 0
max_idx = min_idx + (@limit - 1)
# Get a full page even if at the end
ensure_full_page(min_idx, max_idx)
end
def ensure_full_page(min, max)
upper_limit = (filtered_post_ids.length - 1)
if max >= upper_limit
return (upper_limit - @limit) + 1, upper_limit
else
return min, max
end
end
def closest_post_to(post_number)
# happy path
closest_post = @filtered_posts.where("post_number = ?", post_number).limit(1).pluck(:id)
if closest_post.empty?
# less happy path, missing post
closest_post = @filtered_posts.order("@(post_number - #{post_number})").limit(1).pluck(:id)
end
return nil if closest_post.empty?
filtered_post_ids.index(closest_post.first) || filtered_post_ids[0]
end
end