mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
30990006a9
This reduces chances of errors where consumers of strings mutate inputs and reduces memory usage of the app. Test suite passes now, but there may be some stuff left, so we will run a few sites on a branch prior to merging
1015 lines
29 KiB
Ruby
1015 lines
29 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_dependency 'search/grouped_search_results'
|
|
|
|
class Search
|
|
DIACRITICS ||= /([\u0300-\u036f]|[\u1AB0-\u1AFF]|[\u1DC0-\u1DFF]|[\u20D0-\u20FF])/
|
|
|
|
cattr_accessor :preloaded_topic_custom_fields
|
|
self.preloaded_topic_custom_fields = Set.new
|
|
|
|
def self.per_facet
|
|
5
|
|
end
|
|
|
|
def self.strip_diacritics(str)
|
|
s = str.unicode_normalize(:nfkd)
|
|
s.gsub!(DIACRITICS, "")
|
|
s.strip!
|
|
s
|
|
end
|
|
|
|
def self.per_filter
|
|
50
|
|
end
|
|
|
|
# Sometimes we want more topics than are returned due to exclusion of dupes. This is the
|
|
# factor of extra results we'll ask for.
|
|
def self.burst_factor
|
|
3
|
|
end
|
|
|
|
def self.facets
|
|
%w(topic category user private_messages tags)
|
|
end
|
|
|
|
def self.ts_config(locale = SiteSetting.default_locale)
|
|
# if adding a text search configuration, you should check PG beforehand:
|
|
# SELECT cfgname FROM pg_ts_config;
|
|
# As an aside, dictionaries can be listed by `\dFd`, the
|
|
# physical locations are in /usr/share/postgresql/<version>/tsearch_data.
|
|
# But it may not appear there based on pg extension configuration.
|
|
# base docker config
|
|
#
|
|
case locale.to_sym
|
|
when :da then 'danish'
|
|
when :de then 'german'
|
|
when :en then 'english'
|
|
when :es then 'spanish'
|
|
when :fr then 'french'
|
|
when :it then 'italian'
|
|
when :nl then 'dutch'
|
|
when :nb_NO then 'norwegian'
|
|
when :pt then 'portuguese'
|
|
when :pt_BR then 'portuguese'
|
|
when :sv then 'swedish'
|
|
when :ru then 'russian'
|
|
else 'simple' # use the 'simple' stemmer for other languages
|
|
end
|
|
end
|
|
|
|
def self.prepare_data(search_data, purpose = :query)
|
|
purpose ||= :query
|
|
|
|
data = search_data.dup
|
|
data.force_encoding("UTF-8")
|
|
if purpose != :topic
|
|
# TODO cppjieba_rb is designed for chinese, we need something else for Japanese
|
|
# Korean appears to be safe cause words are already space seperated
|
|
# For Japanese we should investigate using kakasi
|
|
if ['zh_TW', 'zh_CN', 'ja'].include?(SiteSetting.default_locale) || SiteSetting.search_tokenize_chinese_japanese_korean
|
|
require 'cppjieba_rb' unless defined? CppjiebaRb
|
|
mode = (purpose == :query ? :query : :mix)
|
|
data = CppjiebaRb.segment(search_data, mode: mode)
|
|
data = CppjiebaRb.filter_stop_word(data).join(' ')
|
|
else
|
|
data.squish!
|
|
end
|
|
|
|
if SiteSetting.search_ignore_accents
|
|
data = strip_diacritics(data)
|
|
end
|
|
end
|
|
data
|
|
end
|
|
|
|
def self.word_to_date(str)
|
|
|
|
if str =~ /^[0-9]{1,3}$/
|
|
return Time.zone.now.beginning_of_day.days_ago(str.to_i)
|
|
end
|
|
|
|
if str =~ /^([12][0-9]{3})(-([0-1]?[0-9]))?(-([0-3]?[0-9]))?$/
|
|
year = $1.to_i
|
|
month = $2 ? $3.to_i : 1
|
|
day = $4 ? $5.to_i : 1
|
|
|
|
return if day == 0 || month == 0 || day > 31 || month > 12
|
|
|
|
return begin
|
|
Time.zone.parse("#{year}-#{month}-#{day}")
|
|
rescue ArgumentError
|
|
end
|
|
end
|
|
|
|
if str.downcase == "yesterday"
|
|
return Time.zone.now.beginning_of_day.yesterday
|
|
end
|
|
|
|
titlecase = str.downcase.titlecase
|
|
|
|
if Date::DAYNAMES.include?(titlecase)
|
|
return Time.zone.now.beginning_of_week(str.downcase.to_sym)
|
|
end
|
|
|
|
if idx = (Date::MONTHNAMES.find_index(titlecase) ||
|
|
Date::ABBR_MONTHNAMES.find_index(titlecase))
|
|
delta = Time.zone.now.month - idx
|
|
delta += 12 if delta < 0
|
|
Time.zone.now.beginning_of_month.months_ago(delta)
|
|
end
|
|
end
|
|
|
|
def self.min_post_id_no_cache
|
|
return 0 unless SiteSetting.search_prefer_recent_posts?
|
|
|
|
offset, has_more = Post.unscoped
|
|
.order('id desc')
|
|
.offset(SiteSetting.search_recent_posts_size - 1)
|
|
.limit(2)
|
|
.pluck(:id)
|
|
|
|
has_more ? offset : 0
|
|
end
|
|
|
|
def self.min_post_id(opts = nil)
|
|
return 0 unless SiteSetting.search_prefer_recent_posts?
|
|
|
|
# It can be quite slow to count all the posts so let's cache it
|
|
Rails.cache.fetch("search-min-post-id:#{SiteSetting.search_recent_posts_size}", expires_in: 1.week) do
|
|
min_post_id_no_cache
|
|
end
|
|
end
|
|
|
|
attr_accessor :term
|
|
attr_reader :clean_term
|
|
|
|
def initialize(term, opts = nil)
|
|
@opts = opts || {}
|
|
@guardian = @opts[:guardian] || Guardian.new
|
|
@search_context = @opts[:search_context]
|
|
@include_blurbs = @opts[:include_blurbs] || false
|
|
@blurb_length = @opts[:blurb_length]
|
|
@valid = true
|
|
@page = @opts[:page]
|
|
|
|
term = term.to_s.dup
|
|
|
|
# Removes any zero-width characters from search terms
|
|
term.gsub!(/[\u200B-\u200D\uFEFF]/, '')
|
|
# Replace curly quotes to regular quotes
|
|
term.gsub!(/[\u201c\u201d]/, '"')
|
|
|
|
@clean_term = term
|
|
@in_title = false
|
|
|
|
term = process_advanced_search!(term)
|
|
|
|
if term.present?
|
|
@term = Search.prepare_data(term, Topic === @search_context ? :topic : nil)
|
|
@original_term = PG::Connection.escape_string(@term)
|
|
end
|
|
|
|
if @search_pms && @guardian.user
|
|
@opts[:type_filter] = "private_messages"
|
|
@search_context = @guardian.user
|
|
end
|
|
|
|
@results = GroupedSearchResults.new(
|
|
@opts[:type_filter],
|
|
clean_term,
|
|
@search_context,
|
|
@include_blurbs,
|
|
@blurb_length
|
|
)
|
|
end
|
|
|
|
def limit
|
|
if @opts[:type_filter].present?
|
|
Search.per_filter + 1
|
|
else
|
|
Search.per_facet + 1
|
|
end
|
|
end
|
|
|
|
def offset
|
|
if @page && @opts[:type_filter].present?
|
|
(@page - 1) * Search.per_filter
|
|
else
|
|
0
|
|
end
|
|
end
|
|
|
|
def valid?
|
|
@valid
|
|
end
|
|
|
|
def self.execute(term, opts = nil)
|
|
self.new(term, opts).execute
|
|
end
|
|
|
|
# Query a term
|
|
def execute
|
|
if SiteSetting.log_search_queries? && @opts[:search_type].present?
|
|
status, search_log_id = SearchLog.log(
|
|
term: @term,
|
|
search_type: @opts[:search_type],
|
|
ip_address: @opts[:ip_address],
|
|
user_id: @opts[:user_id]
|
|
)
|
|
@results.search_log_id = search_log_id unless status == :error
|
|
end
|
|
|
|
unless @filters.present? || @opts[:search_for_id]
|
|
min_length = @opts[:min_search_term_length] || SiteSetting.min_search_term_length
|
|
terms = (@term || '').split(/\s(?=(?:[^"]|"[^"]*")*$)/).reject { |t| t.length < min_length }
|
|
|
|
if terms.blank?
|
|
@term = ''
|
|
@valid = false
|
|
return
|
|
end
|
|
end
|
|
|
|
# If the term is a number or url to a topic, just include that topic
|
|
if @opts[:search_for_id] && (@results.type_filter == 'topic' || @results.type_filter == 'private_messages')
|
|
if @term =~ /^\d+$/
|
|
single_topic(@term.to_i)
|
|
else
|
|
begin
|
|
route = Rails.application.routes.recognize_path(@term)
|
|
single_topic(route[:topic_id]) if route[:topic_id].present?
|
|
rescue ActionController::RoutingError
|
|
end
|
|
end
|
|
end
|
|
|
|
find_grouped_results if @results.posts.blank?
|
|
|
|
if preloaded_topic_custom_fields.present? && @results.posts.present?
|
|
topics = @results.posts.map(&:topic)
|
|
Topic.preload_custom_fields(topics, preloaded_topic_custom_fields)
|
|
end
|
|
|
|
@results
|
|
end
|
|
|
|
def self.advanced_filter(trigger, &block)
|
|
(@advanced_filters ||= {})[trigger] = block
|
|
end
|
|
|
|
def self.advanced_filters
|
|
@advanced_filters
|
|
end
|
|
|
|
advanced_filter(/^status:open$/) do |posts|
|
|
posts.where('NOT topics.closed AND NOT topics.archived')
|
|
end
|
|
|
|
advanced_filter(/^status:closed$/) do |posts|
|
|
posts.where('topics.closed')
|
|
end
|
|
|
|
advanced_filter(/^status:archived$/) do |posts|
|
|
posts.where('topics.archived')
|
|
end
|
|
|
|
advanced_filter(/^status:noreplies$/) do |posts|
|
|
posts.where("topics.posts_count = 1")
|
|
end
|
|
|
|
advanced_filter(/^status:single_user$/) do |posts|
|
|
posts.where("topics.participant_count = 1")
|
|
end
|
|
|
|
advanced_filter(/^posts_count:(\d+)$/) do |posts, match|
|
|
posts.where("topics.posts_count = ?", match.to_i)
|
|
end
|
|
|
|
advanced_filter(/^min_post_count:(\d+)$/) do |posts, match|
|
|
posts.where("topics.posts_count >= ?", match.to_i)
|
|
end
|
|
|
|
advanced_filter(/^in:first|^f$/) do |posts|
|
|
posts.where("posts.post_number = 1")
|
|
end
|
|
|
|
advanced_filter(/^in:pinned$/) do |posts|
|
|
posts.where("topics.pinned_at IS NOT NULL")
|
|
end
|
|
|
|
advanced_filter(/^in:unpinned$/) do |posts|
|
|
if @guardian.user
|
|
posts.where("topics.pinned_at IS NOT NULL AND topics.id IN (
|
|
SELECT topic_id FROM topic_users WHERE user_id = ? AND cleared_pinned_at IS NOT NULL
|
|
)", @guardian.user.id)
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^in:wiki$/) do |posts, match|
|
|
posts.where(wiki: true)
|
|
end
|
|
|
|
advanced_filter(/^badge:(.*)$/) do |posts, match|
|
|
badge_id = Badge.where('name ilike ? OR id = ?', match, match.to_i).pluck(:id).first
|
|
if badge_id
|
|
posts.where('posts.user_id IN (SELECT ub.user_id FROM user_badges ub WHERE ub.badge_id = ?)', badge_id)
|
|
else
|
|
posts.where("1 = 0")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^in:(likes|bookmarks)$/) do |posts, match|
|
|
if @guardian.user
|
|
post_action_type = PostActionType.types[:like] if match == "likes"
|
|
post_action_type = PostActionType.types[:bookmark] if match == "bookmarks"
|
|
|
|
posts.where("posts.id IN (
|
|
SELECT pa.post_id FROM post_actions pa
|
|
WHERE pa.user_id = #{@guardian.user.id} AND
|
|
pa.post_action_type_id = #{post_action_type} AND
|
|
deleted_at IS NULL
|
|
)")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^in:posted$/) do |posts|
|
|
posts.where("posts.user_id = #{@guardian.user.id}") if @guardian.user
|
|
end
|
|
|
|
advanced_filter(/^in:(watching|tracking)$/) do |posts, match|
|
|
if @guardian.user
|
|
level = TopicUser.notification_levels[match.to_sym]
|
|
posts.where("posts.topic_id IN (
|
|
SELECT tu.topic_id FROM topic_users tu
|
|
WHERE tu.user_id = :user_id AND
|
|
tu.notification_level >= :level
|
|
)", user_id: @guardian.user.id, level: level)
|
|
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^in:seen$/) do |posts|
|
|
if @guardian.user
|
|
posts
|
|
.joins("INNER JOIN post_timings ON
|
|
post_timings.topic_id = posts.topic_id
|
|
AND post_timings.post_number = posts.post_number
|
|
AND post_timings.user_id = #{ActiveRecord::Base.connection.quote(@guardian.user.id)}
|
|
")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^in:unseen$/) do |posts|
|
|
if @guardian.user
|
|
posts
|
|
.joins("LEFT JOIN post_timings ON
|
|
post_timings.topic_id = posts.topic_id
|
|
AND post_timings.post_number = posts.post_number
|
|
AND post_timings.user_id = #{ActiveRecord::Base.connection.quote(@guardian.user.id)}
|
|
")
|
|
.where("post_timings.user_id IS NULL")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^with:images$/) do |posts|
|
|
posts.where("posts.image_url IS NOT NULL")
|
|
end
|
|
|
|
advanced_filter(/^category:(.+)$/) do |posts, match|
|
|
exact = false
|
|
|
|
if match[0] == "="
|
|
exact = true
|
|
match = match[1..-1]
|
|
end
|
|
|
|
category_ids = Category.where('slug ilike ? OR name ilike ? OR id = ?',
|
|
match, match, match.to_i).pluck(:id)
|
|
if category_ids.present?
|
|
|
|
unless exact
|
|
category_ids +=
|
|
Category.where('parent_category_id = ?', category_ids.first).pluck(:id)
|
|
end
|
|
|
|
@category_filter_matched ||= true
|
|
posts.where("topics.category_id IN (?)", category_ids)
|
|
else
|
|
posts.where("1 = 0")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^\#([\p{L}0-9\-:=]+)$/) do |posts, match|
|
|
|
|
exact = true
|
|
|
|
category_slug, subcategory_slug = match.to_s.split(":")
|
|
next unless category_slug
|
|
|
|
if subcategory_slug
|
|
# sub category
|
|
parent_category_id = Category
|
|
.where(
|
|
"lower(slug) = ? AND parent_category_id IS NULL", category_slug.downcase
|
|
)
|
|
.pluck(:id)
|
|
.first
|
|
|
|
category_id = Category
|
|
.where("lower(slug) = ? AND parent_category_id = ?",
|
|
subcategory_slug.downcase, parent_category_id
|
|
)
|
|
.pluck(:id)
|
|
.first
|
|
else
|
|
# main category
|
|
if category_slug[0] == "="
|
|
category_slug = category_slug[1..-1]
|
|
else
|
|
exact = false
|
|
end
|
|
|
|
category_id = Category.where("lower(slug) = ?", category_slug.downcase)
|
|
.order('case when parent_category_id is null then 0 else 1 end')
|
|
.pluck(:id)
|
|
.first
|
|
end
|
|
|
|
if category_id
|
|
category_ids = [category_id]
|
|
|
|
unless exact
|
|
category_ids +=
|
|
Category.where('parent_category_id = ?', category_id).pluck(:id)
|
|
end
|
|
|
|
@category_filter_matched ||= true
|
|
posts.where("topics.category_id IN (?)", category_ids)
|
|
else
|
|
# try a possible tag match
|
|
tag_id = Tag.where_name(category_slug).pluck(:id).first
|
|
if (tag_id)
|
|
posts.where("topics.id IN (
|
|
SELECT DISTINCT(tt.topic_id)
|
|
FROM topic_tags tt
|
|
WHERE tt.tag_id = ?
|
|
)", tag_id)
|
|
else
|
|
# a bit yucky but we got to add the term back in
|
|
if match.to_s.length >= SiteSetting.min_search_term_length
|
|
posts.where("posts.id IN (
|
|
SELECT post_id FROM post_search_data pd1
|
|
WHERE pd1.search_data @@ #{Search.ts_query(term: "##{match}")})")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^group:(.+)$/) do |posts, match|
|
|
group_id = Group.where('name ilike ? OR (id = ? AND id > 0)', match, match.to_i).pluck(:id).first
|
|
if group_id
|
|
posts.where("posts.user_id IN (select gu.user_id from group_users gu where gu.group_id = ?)", group_id)
|
|
else
|
|
posts.where("1 = 0")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^user:(.+)$/) do |posts, match|
|
|
user_id = User.where(staged: false).where('username_lower = ? OR id = ?', match.downcase, match.to_i).pluck(:id).first
|
|
if user_id
|
|
posts.where("posts.user_id = #{user_id}")
|
|
else
|
|
posts.where("1 = 0")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^\@([a-zA-Z0-9_\-.]+)$/) do |posts, match|
|
|
user_id = User.where(staged: false).where(username_lower: match.downcase).pluck(:id).first
|
|
if user_id
|
|
posts.where("posts.user_id = #{user_id}")
|
|
else
|
|
posts.where("1 = 0")
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^before:(.*)$/) do |posts, match|
|
|
if date = Search.word_to_date(match)
|
|
posts.where("posts.created_at < ?", date)
|
|
else
|
|
posts
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^after:(.*)$/) do |posts, match|
|
|
if date = Search.word_to_date(match)
|
|
posts.where("posts.created_at > ?", date)
|
|
else
|
|
posts
|
|
end
|
|
end
|
|
|
|
advanced_filter(/^tags?:([\p{L}0-9,\-_+]+)$/) do |posts, match|
|
|
search_tags(posts, match, positive: true)
|
|
end
|
|
|
|
advanced_filter(/^\-tags?:([\p{L}0-9,\-_+]+)$/) do |posts, match|
|
|
search_tags(posts, match, positive: false)
|
|
end
|
|
|
|
advanced_filter(/^filetypes?:([a-zA-Z0-9,\-_]+)$/) do |posts, match|
|
|
file_extensions = match.split(",").map(&:downcase)
|
|
posts.where("posts.id IN (
|
|
SELECT post_id
|
|
FROM topic_links
|
|
WHERE extension IN (:file_extensions)
|
|
|
|
UNION
|
|
|
|
SELECT post_uploads.post_id
|
|
FROM uploads
|
|
JOIN post_uploads ON post_uploads.upload_id = uploads.id
|
|
WHERE lower(uploads.extension) IN (:file_extensions)
|
|
)", file_extensions: file_extensions)
|
|
end
|
|
|
|
private
|
|
|
|
def search_tags(posts, match, positive:)
|
|
return if match.nil?
|
|
match.downcase!
|
|
modifier = positive ? "" : "NOT"
|
|
|
|
if match.include?('+')
|
|
tags = match.split('+')
|
|
|
|
posts.where("topics.id #{modifier} IN (
|
|
SELECT tt.topic_id
|
|
FROM topic_tags tt, tags
|
|
WHERE tt.tag_id = tags.id
|
|
GROUP BY tt.topic_id
|
|
HAVING to_tsvector(#{default_ts_config}, array_to_string(array_agg(lower(tags.name)), ' ')) @@ to_tsquery(#{default_ts_config}, ?)
|
|
)", tags.join('&'))
|
|
else
|
|
tags = match.split(",")
|
|
|
|
posts.where("topics.id #{modifier} IN (
|
|
SELECT DISTINCT(tt.topic_id)
|
|
FROM topic_tags tt, tags
|
|
WHERE tt.tag_id = tags.id AND lower(tags.name) IN (?)
|
|
)", tags)
|
|
end
|
|
end
|
|
|
|
def process_advanced_search!(term)
|
|
term.to_s.scan(/(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/).to_a.map do |(word, _)|
|
|
next if word.blank?
|
|
|
|
found = false
|
|
|
|
Search.advanced_filters.each do |matcher, block|
|
|
cleaned = word.gsub(/["']/, "")
|
|
if cleaned =~ matcher
|
|
(@filters ||= []) << [block, $1]
|
|
found = true
|
|
end
|
|
end
|
|
|
|
if word == 'order:latest' || word == 'l'
|
|
@order = :latest
|
|
nil
|
|
elsif word == 'order:latest_topic'
|
|
@order = :latest_topic
|
|
nil
|
|
elsif word == 'in:title' || word == 't'
|
|
@in_title = true
|
|
nil
|
|
elsif word =~ /topic:(\d+)/
|
|
topic_id = $1.to_i
|
|
if topic_id > 1
|
|
topic = Topic.find_by(id: topic_id)
|
|
if @guardian.can_see?(topic)
|
|
@search_context = topic
|
|
end
|
|
end
|
|
nil
|
|
elsif word == 'order:views'
|
|
@order = :views
|
|
nil
|
|
elsif word == 'order:likes'
|
|
@order = :likes
|
|
nil
|
|
elsif word == 'in:private'
|
|
@search_pms = true
|
|
nil
|
|
elsif word =~ /^private_messages:(.+)$/
|
|
@search_pms = true
|
|
nil
|
|
else
|
|
found ? nil : word
|
|
end
|
|
end.compact.join(' ')
|
|
end
|
|
|
|
def find_grouped_results
|
|
if @results.type_filter.present?
|
|
raise Discourse::InvalidAccess.new("invalid type filter") unless Search.facets.include?(@results.type_filter)
|
|
# calling protected methods
|
|
send("#{@results.type_filter}_search")
|
|
else
|
|
unless @search_context
|
|
user_search if @term.present?
|
|
category_search if @term.present?
|
|
tags_search if @term.present?
|
|
groups_search if @term.present?
|
|
end
|
|
topic_search
|
|
end
|
|
|
|
add_more_topics_if_expected
|
|
@results
|
|
rescue ActiveRecord::StatementInvalid
|
|
# In the event of a PG:Error return nothing, it is likely they used a foreign language whose
|
|
# locale is not supported by postgres
|
|
end
|
|
|
|
# Add more topics if we expected them
|
|
def add_more_topics_if_expected
|
|
expected_topics = 0
|
|
expected_topics = Search.facets.size unless @results.type_filter.present?
|
|
expected_topics = Search.per_facet * Search.facets.size if @results.type_filter == 'topic'
|
|
expected_topics -= @results.posts.length
|
|
if expected_topics > 0
|
|
extra_posts = posts_query(expected_topics * Search.burst_factor)
|
|
extra_posts = extra_posts.where("posts.topic_id NOT in (?)", @results.posts.map(&:topic_id)) if @results.posts.present?
|
|
extra_posts.each do |post|
|
|
@results.add(post)
|
|
expected_topics -= 1
|
|
break if expected_topics == 0
|
|
end
|
|
end
|
|
end
|
|
|
|
# If we're searching for a single topic
|
|
def single_topic(id)
|
|
if @opts[:restrict_to_archetype].present?
|
|
archetype = @opts[:restrict_to_archetype] == Archetype.default ? Archetype.default : Archetype.private_message
|
|
post = Post.joins(:topic)
|
|
.where("topics.id = :id AND topics.archetype = :archetype AND posts.post_number = 1", id: id, archetype: archetype)
|
|
.first
|
|
else
|
|
post = Post.find_by(topic_id: id, post_number: 1)
|
|
end
|
|
return nil unless @guardian.can_see?(post)
|
|
|
|
@results.add(post)
|
|
@results
|
|
end
|
|
|
|
def secure_category_ids
|
|
return @secure_category_ids unless @secure_category_ids.nil?
|
|
@secure_category_ids = @guardian.secure_category_ids
|
|
end
|
|
|
|
def category_search
|
|
# scope is leaking onto Category, this is not good and probably a bug in Rails
|
|
# the secure_category_ids will invoke the same method on User, it calls Category.where
|
|
# however the scope from the query below is leaking in to Category, this works around
|
|
# the issue while we figure out what is up in Rails
|
|
secure_category_ids
|
|
|
|
categories = Category.includes(:category_search_data)
|
|
.where("category_search_data.search_data @@ #{ts_query}")
|
|
.references(:category_search_data)
|
|
.order("topics_month DESC")
|
|
.secured(@guardian)
|
|
.limit(limit)
|
|
|
|
categories.each do |category|
|
|
@results.add(category)
|
|
end
|
|
end
|
|
|
|
def user_search
|
|
return if SiteSetting.hide_user_profiles_from_public && !@guardian.user
|
|
|
|
users = User.includes(:user_search_data)
|
|
.references(:user_search_data)
|
|
.where(active: true)
|
|
.where(staged: false)
|
|
.where("user_search_data.search_data @@ #{ts_query("simple")}")
|
|
.order("CASE WHEN username_lower = '#{@original_term.downcase}' THEN 0 ELSE 1 END")
|
|
.order("last_posted_at DESC")
|
|
.limit(limit)
|
|
|
|
users.each do |user|
|
|
@results.add(user)
|
|
end
|
|
end
|
|
|
|
def groups_search
|
|
groups = Group
|
|
.visible_groups(@guardian.user, "name ASC", include_everyone: false)
|
|
.where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%")
|
|
|
|
groups.each { |group| @results.add(group) }
|
|
end
|
|
|
|
def tags_search
|
|
return unless SiteSetting.tagging_enabled
|
|
|
|
tags = Tag.includes(:tag_search_data)
|
|
.where("tag_search_data.search_data @@ #{ts_query}")
|
|
.references(:tag_search_data)
|
|
.order("name asc")
|
|
.limit(limit)
|
|
|
|
tags.each do |tag|
|
|
@results.add(tag)
|
|
end
|
|
end
|
|
|
|
PHRASE_MATCH_REGEXP_PATTERN = '"([^"]+)"'
|
|
|
|
def posts_query(limit, opts = nil)
|
|
opts ||= {}
|
|
|
|
posts = Post.where(post_type: Topic.visible_post_types(@guardian.user))
|
|
.joins(:post_search_data, :topic)
|
|
.joins("LEFT JOIN categories ON categories.id = topics.category_id")
|
|
|
|
is_topic_search = @search_context.present? && @search_context.is_a?(Topic)
|
|
|
|
posts = posts.where("topics.visible") unless is_topic_search
|
|
|
|
if opts[:private_messages] || (is_topic_search && @search_context.private_message?)
|
|
posts = posts.where("topics.archetype = ?", Archetype.private_message)
|
|
|
|
unless @guardian.is_admin?
|
|
posts = posts.private_posts_for_user(@guardian.user)
|
|
end
|
|
else
|
|
posts = posts.where("topics.archetype <> ?", Archetype.private_message)
|
|
end
|
|
|
|
if @term.present?
|
|
if is_topic_search
|
|
|
|
term_without_quote = @term
|
|
if @term =~ /"(.+)"/
|
|
term_without_quote = $1
|
|
end
|
|
|
|
if @term =~ /'(.+)'/
|
|
term_without_quote = $1
|
|
end
|
|
|
|
posts = posts.joins('JOIN users u ON u.id = posts.user_id')
|
|
posts = posts.where("posts.raw || ' ' || u.username || ' ' || COALESCE(u.name, '') ilike ?", "%#{term_without_quote}%")
|
|
else
|
|
# A is for title
|
|
# B is for category
|
|
# C is for tags
|
|
# D is for cooked
|
|
weights = @in_title ? 'A' : (SiteSetting.tagging_enabled ? 'ABCD' : 'ABD')
|
|
posts = posts.where("post_search_data.search_data @@ #{ts_query(weight_filter: weights)}")
|
|
exact_terms = @term.scan(Regexp.new(PHRASE_MATCH_REGEXP_PATTERN)).flatten
|
|
|
|
exact_terms.each do |exact|
|
|
posts = posts.where("posts.raw ilike :exact OR topics.title ilike :exact", exact: "%#{exact}%")
|
|
end
|
|
end
|
|
end
|
|
|
|
@filters.each do |block, match|
|
|
if block.arity == 1
|
|
posts = instance_exec(posts, &block) || posts
|
|
else
|
|
posts = instance_exec(posts, match, &block) || posts
|
|
end
|
|
end if @filters
|
|
|
|
# If we have a search context, prioritize those posts first
|
|
posts =
|
|
if @search_context.present?
|
|
if @search_context.is_a?(User)
|
|
if opts[:private_messages]
|
|
posts.private_posts_for_user(@search_context)
|
|
else
|
|
posts.where("posts.user_id = #{@search_context.id}")
|
|
end
|
|
elsif @search_context.is_a?(Category)
|
|
category_ids = Category
|
|
.where(parent_category_id: @search_context.id)
|
|
.pluck(:id)
|
|
.push(@search_context.id)
|
|
|
|
posts.where("topics.category_id in (?)", category_ids)
|
|
elsif @search_context.is_a?(Topic)
|
|
posts.where("topics.id = #{@search_context.id}")
|
|
.order("posts.post_number #{@order == :latest ? "DESC" : ""}")
|
|
end
|
|
else
|
|
posts = categories_ignored(posts) unless @category_filter_matched
|
|
posts
|
|
end
|
|
|
|
if @order == :latest || (@term.blank? && !@order)
|
|
if opts[:aggregate_search]
|
|
posts = posts.order("MAX(posts.created_at) DESC")
|
|
else
|
|
posts = posts.reorder("posts.created_at DESC")
|
|
end
|
|
elsif @order == :latest_topic
|
|
if opts[:aggregate_search]
|
|
posts = posts.order("MAX(topics.created_at) DESC")
|
|
else
|
|
posts = posts.order("topics.created_at DESC")
|
|
end
|
|
elsif @order == :views
|
|
if opts[:aggregate_search]
|
|
posts = posts.order("MAX(topics.views) DESC")
|
|
else
|
|
posts = posts.order("topics.views DESC")
|
|
end
|
|
elsif @order == :likes
|
|
if opts[:aggregate_search]
|
|
posts = posts.order("MAX(posts.like_count) DESC")
|
|
else
|
|
posts = posts.order("posts.like_count DESC")
|
|
end
|
|
else
|
|
# 1|32 divides the rank by 1 + logarithm of the document length and
|
|
# scales the range from zero to one
|
|
data_ranking = <<~SQL
|
|
(
|
|
TS_RANK_CD(
|
|
post_search_data.search_data,
|
|
#{ts_query(weight_filter: weights)},
|
|
1|32
|
|
) *
|
|
(
|
|
CASE categories.search_priority
|
|
WHEN #{Searchable::PRIORITIES[:very_low]}
|
|
THEN #{SiteSetting.category_search_priority_very_low_weight}
|
|
WHEN #{Searchable::PRIORITIES[:low]}
|
|
THEN #{SiteSetting.category_search_priority_low_weight}
|
|
WHEN #{Searchable::PRIORITIES[:high]}
|
|
THEN #{SiteSetting.category_search_priority_high_weight}
|
|
WHEN #{Searchable::PRIORITIES[:very_high]}
|
|
THEN #{SiteSetting.category_search_priority_very_high_weight}
|
|
ELSE 1
|
|
END
|
|
)
|
|
)
|
|
SQL
|
|
|
|
if opts[:aggregate_search]
|
|
posts = posts.order("MAX(#{data_ranking}) DESC")
|
|
else
|
|
posts = posts.order("#{data_ranking} DESC")
|
|
end
|
|
|
|
posts = posts.order("topics.bumped_at DESC")
|
|
end
|
|
|
|
if secure_category_ids.present?
|
|
posts = posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted) OR (categories.id IN (?))", secure_category_ids).references(:categories)
|
|
else
|
|
posts = posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted)").references(:categories)
|
|
end
|
|
|
|
posts = posts.offset(offset)
|
|
posts.limit(limit)
|
|
end
|
|
|
|
def categories_ignored(posts)
|
|
posts.where(<<~SQL, Searchable::PRIORITIES[:ignore])
|
|
categories.id NOT IN (
|
|
SELECT categories.id WHERE categories.search_priority = ?
|
|
)
|
|
SQL
|
|
end
|
|
|
|
def self.default_ts_config
|
|
"'#{Search.ts_config}'"
|
|
end
|
|
|
|
def default_ts_config
|
|
self.class.default_ts_config
|
|
end
|
|
|
|
def self.ts_query(term: , ts_config: nil, joiner: "&", weight_filter: nil)
|
|
|
|
data = DB.query_single(
|
|
"SELECT TO_TSVECTOR(:config, :term)",
|
|
config: 'simple',
|
|
term: term
|
|
).first
|
|
|
|
ts_config = ActiveRecord::Base.connection.quote(ts_config) if ts_config
|
|
all_terms = data.scan(/'([^']+)'\:\d+/).flatten
|
|
all_terms.map! do |t|
|
|
t.split(/[\)\(&']/).find(&:present?)
|
|
end.compact!
|
|
|
|
query = ActiveRecord::Base.connection.quote(
|
|
all_terms
|
|
.map { |t| "'#{PG::Connection.escape_string(t)}':*#{weight_filter}" }
|
|
.join(" #{joiner} ")
|
|
)
|
|
|
|
"TO_TSQUERY(#{ts_config || default_ts_config}, #{query})"
|
|
end
|
|
|
|
def ts_query(ts_config = nil, weight_filter: nil)
|
|
@ts_query_cache ||= {}
|
|
@ts_query_cache["#{ts_config || default_ts_config} #{@term} #{weight_filter}"] ||=
|
|
Search.ts_query(term: @term, ts_config: ts_config, weight_filter: weight_filter)
|
|
end
|
|
|
|
def wrap_rows(query)
|
|
"SELECT *, row_number() over() row_number FROM (#{query.to_sql}) xxx"
|
|
end
|
|
|
|
def aggregate_post_sql(opts)
|
|
min_or_max = @order == :latest ? "max" : "min"
|
|
|
|
query =
|
|
if @order == :likes
|
|
# likes are a pain to aggregate so skip
|
|
posts_query(limit, private_messages: opts[:private_messages])
|
|
.select('topics.id', "posts.post_number")
|
|
else
|
|
posts_query(limit, aggregate_search: true, private_messages: opts[:private_messages])
|
|
.select('topics.id', "#{min_or_max}(posts.post_number) post_number")
|
|
.group('topics.id')
|
|
end
|
|
|
|
min_id = Search.min_post_id
|
|
if min_id > 0
|
|
low_set = query.dup.where("post_search_data.post_id < #{min_id}")
|
|
high_set = query.where("post_search_data.post_id >= #{min_id}")
|
|
|
|
return { default: wrap_rows(high_set), remaining: wrap_rows(low_set) }
|
|
end
|
|
|
|
# double wrapping so we get correct row numbers
|
|
{ default: wrap_rows(query) }
|
|
end
|
|
|
|
def aggregate_posts(post_sql)
|
|
return [] unless post_sql
|
|
|
|
posts_eager_loads(Post)
|
|
.joins("JOIN (#{post_sql}) x ON x.id = posts.topic_id AND x.post_number = posts.post_number")
|
|
.order('row_number')
|
|
end
|
|
|
|
def aggregate_search(opts = {})
|
|
post_sql = aggregate_post_sql(opts)
|
|
|
|
added = 0
|
|
|
|
aggregate_posts(post_sql[:default]).each do |p|
|
|
@results.add(p)
|
|
added += 1
|
|
end
|
|
|
|
if added < limit
|
|
aggregate_posts(post_sql[:remaining]).each { |p| @results.add(p) }
|
|
end
|
|
end
|
|
|
|
def private_messages_search
|
|
raise Discourse::InvalidAccess.new("anonymous can not search PMs") unless @guardian.user
|
|
|
|
aggregate_search(private_messages: true)
|
|
end
|
|
|
|
def topic_search
|
|
if @search_context.is_a?(Topic)
|
|
posts = posts_eager_loads(posts_query(limit))
|
|
.where('posts.topic_id = ?', @search_context.id)
|
|
|
|
posts.each do |post|
|
|
@results.add(post)
|
|
end
|
|
else
|
|
aggregate_search
|
|
end
|
|
end
|
|
|
|
def posts_eager_loads(query)
|
|
query = query.includes(:user)
|
|
topic_eager_loads = [:category]
|
|
|
|
if SiteSetting.tagging_enabled
|
|
topic_eager_loads << :tags
|
|
end
|
|
|
|
query.includes(topic: topic_eager_loads)
|
|
end
|
|
|
|
end
|