2013-05-22 14:36:14 -04:00
|
|
|
class Search
|
2013-02-05 14:16:51 -05:00
|
|
|
|
|
|
|
def self.per_facet
|
|
|
|
5
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.facets
|
|
|
|
%w(topic category user)
|
|
|
|
end
|
|
|
|
|
2013-02-28 14:14:22 -05:00
|
|
|
def self.current_locale_long
|
|
|
|
case I18n.locale # Currently-present in /conf/locales/* only, sorry :-( Add as needed
|
2013-04-10 13:45:29 -04:00
|
|
|
when :da then 'danish'
|
|
|
|
when :de then 'german'
|
|
|
|
when :en then 'english'
|
|
|
|
when :es then 'spanish'
|
2013-02-28 14:14:22 -05:00
|
|
|
when :fr then 'french'
|
2013-04-10 13:45:29 -04:00
|
|
|
when :it then 'italian'
|
2013-02-28 14:14:22 -05:00
|
|
|
when :nl then 'dutch'
|
2013-04-10 13:45:29 -04:00
|
|
|
when :pt then 'portuguese'
|
2013-02-28 14:14:22 -05:00
|
|
|
when :sv then 'swedish'
|
2013-04-10 13:45:29 -04:00
|
|
|
else 'simple' # use the 'simple' stemmer for other languages
|
2013-02-28 14:14:22 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
def initialize(term, opts=nil)
|
|
|
|
@term = term.to_s if term.present?
|
|
|
|
@opts = opts || {}
|
|
|
|
@guardian = @opts[:guardian] || Guardian.new
|
2013-05-13 17:04:41 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# Query a term
|
2013-05-22 14:36:14 -04:00
|
|
|
def execute
|
2013-05-13 17:04:41 -04:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
return nil if @term.blank?
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
# really short terms are totally pointless
|
|
|
|
return nil if @term.length < (@opts[:min_search_term_length] || SiteSetting.min_search_term_length)
|
2013-05-13 17:04:41 -04:00
|
|
|
|
|
|
|
# If the term is a number or url to a topic, just include that topic
|
2013-05-22 14:36:14 -04:00
|
|
|
if @opts[:type_filter] == 'topic'
|
2013-05-13 17:04:41 -04:00
|
|
|
|
|
|
|
begin
|
2013-05-22 14:36:14 -04:00
|
|
|
route = Rails.application.routes.recognize_path(@term)
|
|
|
|
return single_topic(route[:topic_id]) if route[:topic_id].present?
|
2013-05-13 17:04:41 -04:00
|
|
|
rescue ActionController::RoutingError
|
|
|
|
end
|
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
return single_topic(@term.to_i) if @term =~ /^\d+$/
|
2013-05-13 17:04:41 -04:00
|
|
|
end
|
2013-03-01 12:45:25 -05:00
|
|
|
|
2013-02-28 14:52:35 -05:00
|
|
|
# We are stripping only symbols taking place in FTS and simply sanitizing the rest.
|
2013-05-22 14:36:14 -04:00
|
|
|
@term = PG::Connection.escape_string(@term.gsub(/[:()&!]/,''))
|
|
|
|
|
|
|
|
query_string
|
2013-05-13 17:04:41 -04:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
private
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
# Search for a string term
|
|
|
|
def query_string
|
|
|
|
|
|
|
|
args = {orig: @term,
|
|
|
|
query: @term.split.map {|t| "#{t}:*"}.join(" & "),
|
|
|
|
locale: Search.current_locale_long}
|
|
|
|
|
|
|
|
type_filter = @opts[:type_filter]
|
|
|
|
|
|
|
|
if type_filter.present?
|
|
|
|
raise Discourse::InvalidAccess.new("invalid type filter") unless Search.facets.include?(type_filter)
|
|
|
|
args.merge!(limit: Search.per_facet * Search.facets.size)
|
|
|
|
db_result = case type_filter.to_s
|
|
|
|
when 'topic'
|
|
|
|
post_query(type_filter.to_sym, args)
|
|
|
|
when 'category'
|
|
|
|
category_query(args)
|
|
|
|
when 'user'
|
|
|
|
user_query(args)
|
|
|
|
end
|
|
|
|
else
|
|
|
|
args.merge!(limit: (Search.per_facet + 1))
|
|
|
|
db_result = []
|
|
|
|
db_result += user_query(args).to_a
|
|
|
|
db_result += category_query(args).to_a
|
|
|
|
db_result += post_query(:topic, args).to_a
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
db_result = db_result.to_a
|
2013-02-25 11:42:20 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
expected_topics = 0
|
|
|
|
expected_topics = Search.facets.size unless type_filter.present?
|
|
|
|
expected_topics = Search.per_facet * Search.facets.size if type_filter == 'topic'
|
2013-02-25 11:42:20 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
if expected_topics > 0
|
|
|
|
db_result.each do |row|
|
|
|
|
expected_topics -= 1 if row['type'] == 'topic'
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
2013-02-25 11:42:20 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
if expected_topics > 0
|
|
|
|
tmp = post_query(:post, args.merge(limit: expected_topics * 3)).to_a
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
topic_ids = Set.new db_result.map{|r| r["id"]}
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
tmp.reject! do |i|
|
|
|
|
if topic_ids.include?(i["id"])
|
|
|
|
true
|
|
|
|
else
|
|
|
|
topic_ids << i["id"]
|
|
|
|
false
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
2013-05-22 14:36:14 -04:00
|
|
|
|
|
|
|
db_result += tmp[0..expected_topics-1]
|
2013-05-13 17:04:41 -04:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
group_db_result(db_result)
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
2013-05-13 17:04:41 -04:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
# If we're searching for a single topic
|
|
|
|
def single_topic(id)
|
|
|
|
topic = Topic.where(id: id).first
|
|
|
|
return nil unless @guardian.can_see?(topic)
|
2013-05-13 17:04:41 -04:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
group_db_result([{'type' => 'topic',
|
|
|
|
'id' => topic.id,
|
|
|
|
'url' => topic.relative_url,
|
|
|
|
'title' => topic.title }])
|
|
|
|
end
|
|
|
|
|
|
|
|
def add_allowed_categories(builder)
|
|
|
|
allowed_categories = nil
|
|
|
|
allowed_categories = @guardian.secure_category_ids
|
|
|
|
if allowed_categories.present?
|
|
|
|
builder.where("(c.id IS NULL OR c.secure OR c.id in (:category_ids))", category_ids: allowed_categories)
|
|
|
|
else
|
|
|
|
builder.where("(c.id IS NULL OR (NOT c.secure))")
|
2013-03-11 12:18:25 -04:00
|
|
|
end
|
2013-05-22 14:36:14 -04:00
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-13 17:04:41 -04:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
def category_query(args)
|
|
|
|
builder = SqlBuilder.new <<SQL
|
|
|
|
SELECT 'category' AS type,
|
|
|
|
c.name AS id,
|
|
|
|
'/category/' || c.slug AS url,
|
|
|
|
c.name AS title,
|
|
|
|
NULL AS email,
|
|
|
|
c.color,
|
|
|
|
c.text_color
|
|
|
|
FROM categories AS c
|
2013-05-22 15:33:33 -04:00
|
|
|
JOIN category_search_data s on s.category_id = c.id
|
2013-05-22 14:36:14 -04:00
|
|
|
/*where*/
|
|
|
|
ORDER BY topics_month desc
|
|
|
|
LIMIT :limit
|
|
|
|
SQL
|
|
|
|
|
|
|
|
builder.where "s.search_data @@ TO_TSQUERY(:locale, :query)"
|
|
|
|
add_allowed_categories(builder)
|
2013-02-05 14:16:51 -05:00
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
builder.exec(args)
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
2013-05-22 14:36:14 -04:00
|
|
|
def user_query(args)
|
|
|
|
sql = "SELECT 'user' AS type,
|
|
|
|
u.username_lower AS id,
|
|
|
|
'/users/' || u.username_lower AS url,
|
|
|
|
u.username AS title,
|
|
|
|
u.email,
|
|
|
|
NULL AS color,
|
|
|
|
NULL AS text_color
|
|
|
|
FROM users AS u
|
2013-05-22 15:33:33 -04:00
|
|
|
JOIN user_search_data s on s.user_id = u.id
|
2013-05-22 14:36:14 -04:00
|
|
|
WHERE s.search_data @@ TO_TSQUERY(:locale, :query)
|
|
|
|
ORDER BY CASE WHEN u.username_lower = lower(:orig) then 0 else 1 end, last_posted_at desc
|
|
|
|
LIMIT :limit"
|
|
|
|
ActiveRecord::Base.exec_sql(sql, args)
|
|
|
|
end
|
|
|
|
|
|
|
|
def post_query(type, args)
|
|
|
|
builder = SqlBuilder.new <<SQL
|
|
|
|
/*select*/
|
|
|
|
FROM topics AS ft
|
|
|
|
/*join*/
|
2013-05-22 15:33:33 -04:00
|
|
|
JOIN post_search_data s on s.post_id = p.id
|
2013-05-22 14:36:14 -04:00
|
|
|
LEFT JOIN categories c ON c.id = ft.category_id
|
|
|
|
/*where*/
|
|
|
|
ORDER BY
|
|
|
|
TS_RANK_CD(TO_TSVECTOR(:locale, ft.title), TO_TSQUERY(:locale, :query)) desc,
|
|
|
|
TS_RANK_CD(search_data, TO_TSQUERY(:locale, :query)) desc,
|
|
|
|
bumped_at desc
|
|
|
|
LIMIT :limit
|
|
|
|
SQL
|
|
|
|
|
|
|
|
builder.select "'topic' AS type"
|
|
|
|
builder.select("CAST(ft.id AS VARCHAR)")
|
|
|
|
|
|
|
|
if type == :topic
|
|
|
|
builder.select "'/t/slug/' || ft.id AS url"
|
|
|
|
else
|
|
|
|
builder.select "'/t/slug/' || ft.id || '/' || p.post_number AS url"
|
|
|
|
end
|
|
|
|
|
|
|
|
builder.select "ft.title, NULL AS email, NULL AS color, NULL AS text_color"
|
|
|
|
|
|
|
|
if type == :topic
|
|
|
|
builder.join "posts AS p ON p.topic_id = ft.id AND p.post_number = 1"
|
|
|
|
else
|
|
|
|
builder.join "posts AS p ON p.topic_id = ft.id AND p.post_number > 1"
|
|
|
|
end
|
|
|
|
|
|
|
|
builder.where <<SQL
|
|
|
|
s.search_data @@ TO_TSQUERY(:locale, :query)
|
|
|
|
AND ft.deleted_at IS NULL
|
|
|
|
AND p.deleted_at IS NULL
|
|
|
|
AND ft.visible
|
|
|
|
AND ft.archetype <> '#{Archetype.private_message}'
|
|
|
|
SQL
|
|
|
|
|
|
|
|
add_allowed_categories(builder)
|
|
|
|
|
|
|
|
builder.exec(args)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Group the results by type
|
|
|
|
def group_db_result(db_result)
|
|
|
|
grouped = {}
|
|
|
|
db_result.each do |row|
|
|
|
|
type = row.delete('type')
|
|
|
|
|
|
|
|
# Add the slug for topics
|
|
|
|
if type == 'topic'
|
|
|
|
new_slug = Slug.for(row['title'])
|
|
|
|
new_slug = "topic" if new_slug.blank?
|
|
|
|
row['url'].gsub!('slug', new_slug)
|
2013-05-22 15:33:33 -04:00
|
|
|
elsif type == 'user'
|
|
|
|
row['avatar_template'] = User.avatar_template(row['email'])
|
2013-05-22 14:36:14 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# Remove attributes when we know they don't matter
|
|
|
|
row.delete('email')
|
2013-05-22 15:33:33 -04:00
|
|
|
unless type == 'category'
|
|
|
|
row.delete('color')
|
|
|
|
row.delete('text_color')
|
|
|
|
end
|
2013-05-22 14:36:14 -04:00
|
|
|
|
2013-05-22 15:33:33 -04:00
|
|
|
grouped[type] = (grouped[type] || []) << row
|
2013-05-22 14:36:14 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
grouped.map do |type, results|
|
|
|
|
more = @opts[:type_filter].blank? && (results.size > Search.per_facet)
|
|
|
|
results = results[0..([results.length, Search.per_facet].min - 1)] if @opts[:type_filter].blank?
|
|
|
|
{
|
|
|
|
type: type,
|
|
|
|
name: I18n.t("search.types.#{type}"),
|
|
|
|
more: more,
|
|
|
|
results: results
|
|
|
|
}
|
|
|
|
end
|
2013-02-05 14:16:51 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
end
|