Files
danbooru/app/logical/post_query.rb
evazion f117049750 users: remove 'hide deleted posts' account setting.
This setting automatically added the `-status:deleted` metatag to all searches. This meant deleted
posts were filtered out at the database level, rather than at the html level. This way searches
wouldn't have less-than-full pages.

The cost was that searches were slower, mainly because post counts weren't cached. Normally when you
search for a tag, we can get the post count from the tags table. If the search is actually like
`touhou -status:deleted`, then we don't know the count and we have to calculate it on demand.

This option is being removed because it did the opposite of what people thought it did. People
thought it made deleted posts visible, when actually it made them more hidden.
2022-05-01 00:47:46 -05:00

307 lines
9.2 KiB
Ruby

# frozen_string_literal: true
class PostQuery
extend Memoist
class Error < StandardError; end
class TagLimitError < Error; end
# Metatags that don't count against the user's tag limit.
UNLIMITED_METATAGS = %w[status rating limit]
# Metatags that define the order of search results. These metatags can't be used more than once per query.
ORDER_METATAGS = %w[order ordfav ordfavgroup ordpool]
# Metatags that can't be used more than once per query, and that can't be used with OR or NOT operators.
SINGLETON_METATAGS = ORDER_METATAGS + %w[limit random]
attr_reader :current_user
private attr_reader :tag_limit, :safe_mode, :builder
delegate :tag?, :metatag?, :wildcard?, :metatags, :wildcards, :tag_names, :to_infix, :to_pretty_string, to: :ast
alias_method :safe_mode?, :safe_mode
alias_method :to_s, :to_infix
# Return a new PostQuery with aliases replaced.
def self.normalize(search, ...)
search = search.to_s.strip
# Optimize zero tag and single tag searches
if search.blank?
PostQuery.new(AST.all, ...)
elsif search.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9();/+!?&'._~-]*\z}) && !search.downcase.in?(["and", "or"])
PostQuery.new(AST.tag(search), ...).replace_aliases
else
PostQuery.new(search, ...).replace_aliases.rewrite_opts.trim
end
end
# Perform a search and return the resulting posts
def self.search(search, ...)
PostQuery.normalize(search, ...).with_implicit_metatags.posts
end
def initialize(search_or_ast, current_user: User.anonymous, tag_limit: nil, safe_mode: false)
if search_or_ast.is_a?(AST)
@ast = search_or_ast
else
@search = search_or_ast.to_s
end
@current_user = current_user
@tag_limit = tag_limit
@safe_mode = safe_mode
end
# Build a new PostQuery from the given AST and the current settings.
def build(ast)
PostQuery.new(ast, current_user: current_user, tag_limit: tag_limit, safe_mode: safe_mode)
end
def builder
@builder ||= PostQueryBuilder.new(search, current_user, tag_limit: tag_limit, safe_mode: safe_mode)
end
def search
@search ||= ast.to_infix
end
def ast
@ast ||= Parser.parse(search)
end
def posts
validate!
builder.posts(to_cnf)
end
def paginated_posts(...)
validate!
builder.paginated_posts(to_cnf, ...)
end
# The name of the only tag in the query, if the query contains a single tag. The tag may not exist. The query may contain other metatags or wildcards, and the tag may be negated.
def tag_name
tag_names.first if has_single_tag?
end
# The only tag in the query, if the query contains a single tag. The query may contain other metatags or wildcards, and the tag may be negated.
def tag
tags.first if has_single_tag?
end
# The list of all tags contained in the query.
def tags
Tag.where(name: tag_names)
end
# True if this search would return all posts (normally because the search is the empty string).
def is_empty_search?
ast.all?
end
# True if this search would return nothing (normally because there was a syntax error).
def is_null_search?
ast.none?
end
# True if the search is a single metatag search for the given metatag.
def is_metatag?(name, value = nil)
if value.nil?
is_single_term? && has_metatag?(name)
else
is_single_term? && find_metatag(name) == value.to_s
end
end
# True if the search consists of a single tag, metatag, or wildcard.
def is_single_term?
tag_names.size + metatags.size + wildcards.size == 1
end
# True if this search consists only of a single non-negated tag, with no other metatags or operators.
def is_simple_tag?
ast.tag?
end
# True if the search contains a single tag. It may have other metatags or wildcards, and the tag may be negated.
def has_single_tag?
tag_names.one?
end
# True if the search depends on the current user because of permissions or privacy settings.
def is_user_dependent_search?
metatags.any? do |metatag|
metatag.name.in?(%w[upvoter upvote downvoter downvote search flagger fav ordfav favgroup ordfavgroup]) ||
metatag.name == "status" && metatag.value == "unmoderated" ||
metatag.name == "disapproved" && !metatag.value.downcase.in?(PostDisapproval::REASONS)
end
end
def select_metatags(*names)
metatags.select { |metatag| metatag.name.in?(names.map(&:to_s).map(&:downcase)) }
end
def has_metatag?(*names)
select_metatags(*names).present?
end
def find_metatag(*names)
select_metatags(*names).first&.value
end
# Return a new PostQuery with unnecessary AND and OR clauses eliminated.
def trim
build(ast.trim)
end
# Return a new PostQuery with the '~' operator replaced with OR clauses.
def rewrite_opts
build(ast.rewrite_opts)
end
# Return a new PostQuery with aliases replaced.
def replace_aliases
return self if aliases.empty?
build(ast.replace_tags(aliases))
end
# Return a new PostQuery with implicit metatags (rating:safe and -status:deleted) added.
def with_implicit_metatags
return self if implicit_metatags.empty?
build(AST.new(:and, [ast, *implicit_metatags]))
end
# Return a new PostQuery with terms sorted into alphabetical order.
def sort
build(ast.sort)
end
# Return a new PostQuery converted to conjunctive normal form.
def to_cnf
build(ast.to_cnf)
end
# Return a hash mapping aliased tag names to real tag names.
def aliases
TagAlias.aliases_for(tag_names)
end
# Implicit metatags are metatags added by the user's account settings. rating:s is implicit under safe mode.
def implicit_metatags
metatags = []
metatags << AST.metatag("rating", "s") if safe_mode?
metatags
end
# XXX unify with PostSets::Post#show_deleted?
def hide_deleted?
has_status_metatag = select_metatags(:status).any? { |metatag| metatag.value.downcase.in?(%w[deleted active any all unmoderated modqueue appealed]) }
!has_status_metatag
end
concerning :CountMethods do
def post_count
@post_count ||= fast_count
end
# Return an estimate of the number of posts returned by the search. By default, we try to use an
# estimated or cached count before doing an exact count.
#
# @param timeout [Integer] The database timeout in milliseconds
# @param estimate_count [Boolean] If true, estimate the count with inexact methods.
# @param skip_cache [Boolean] If true, don't use the cached count.
# @return [Integer, nil] The number of posts, or nil on timeout.
def fast_count(timeout: 1_000, estimate_count: true, skip_cache: false)
count = nil
count = estimated_count if estimate_count
count = cached_count(timeout) if count.nil? && !skip_cache
count = exact_count(timeout) if count.nil? && skip_cache
count
end
def estimated_count
if is_empty_search?
estimated_row_count
elsif is_simple_tag?
tag.try(:post_count)
elsif is_metatag?(:rating)
estimated_row_count
elsif is_metatag?(:pool) || is_metatag?(:ordpool)
name = find_metatag(:pool, :ordpool)
Pool.find_by_name(name)&.post_count || 0
elsif is_metatag?(:fav) || is_metatag?(:ordfav)
name = find_metatag(:fav, :ordfav)
user = User.find_by_name(name)
if user.nil?
0
elsif Pundit.policy!(current_user, user).can_see_favorites?
user.favorite_count
else
nil
end
end
end
# Estimate the count by parsing the Postgres EXPLAIN output.
def estimated_row_count
ExplainParser.new(posts).row_count
end
def cached_count(timeout, duration: 5.minutes)
Cache.get(count_cache_key, duration) do
exact_count(timeout)
end
end
def exact_count(timeout)
Post.with_timeout(timeout) do
posts.count
end
end
def count_cache_key
if is_user_dependent_search?
"pfc[#{current_user.id.to_i}]:#{to_s}"
else
"pfc:#{to_s}"
end
end
end
concerning :ValidationMethods do
def validate!
return if is_empty_search? || is_simple_tag?
validate_tag_limit!
validate_metatags!
end
def validate_tag_limit!
raise TagLimitError if tag_limit.present? && term_count > tag_limit
end
def validate_metatags!
return if metatags.empty?
order_metatags = select_metatags(*ORDER_METATAGS)
raise Error, "#{order_metatags.to_sentence} can't be used together." if order_metatags.size > 1
SINGLETON_METATAGS.each do |name|
metatag = select_metatags(name).first
raise Error, "'#{name}:' can't be used more than once." if select_metatags(name).size > 1
raise Error, "'#{metatag}' can't be negated." if metatag&.parents&.any?(&:not?)
raise Error, "'#{metatag}' can't be used with the 'or' operator." if metatag&.parents&.any?(&:or?)
end
end
# The number of unique tags, wildcards, and metatags in the search, excluding metatags that don't count against the user's tag limit.
def term_count
tag_names.size + wildcards.size + metatags.count { !_1.name.in?(UNLIMITED_METATAGS) }
end
end
memoize :tags, :replace_aliases, :with_implicit_metatags, :to_cnf, :aliases, :implicit_metatags, :hide_deleted?, :term_count
end