Fixes bug described in d3e4ac7c17 (commitcomment-39049351)
When dealing with searches, there are several variables we have to keep
in mind:
* Whether tag aliases should be applied.
* Whether search terms should be sorted.
* Whether the rating:s and -status:deleted metatags should be added by
safe mode and the hide deleted posts setting.
Which of these things we need to do depends on the context:
* We want to apply aliases when actually doing the search, calculating
the count, looking up the wiki excerpt, recording missed/popular
searches in Reportbooru, and calculating related tags for the sidebar,
but not when displaying the raw search as typed by the user (for
example, in the page title or in the tag search box).
* We want to sort the search when calculating cache keys for fast_count
or related tags, and when recording missed/popular searches, but not
in the page title or when displaying the raw search.
* We want to add rating:s and -status:deleted when performing the
search, calculating the count, or recording missed/popular searches,
but not when calculating related tags for the sidebar, or when
displaying the page title or raw search.
Here we introduce normalized_query and try to use it in contexts where
query normalization is necessary. When to use the normalized query
versus the raw unnormalized query is still subtle and prone to error.
173 lines
4.2 KiB
Ruby
173 lines
4.2 KiB
Ruby
class SavedSearch < ApplicationRecord
|
|
REDIS_EXPIRY = 1.hour
|
|
QUERY_LIMIT = 1000
|
|
|
|
concerning :Redis do
|
|
extend Memoist
|
|
|
|
class_methods do
|
|
extend Memoist
|
|
|
|
def redis
|
|
::Redis.new(url: Danbooru.config.redis_url)
|
|
end
|
|
memoize :redis
|
|
|
|
def post_ids_for(user_id, label: nil)
|
|
queries = queries_for(user_id, label: label)
|
|
post_ids = Set.new
|
|
queries.each do |query|
|
|
redis_key = "search:#{query}"
|
|
if redis.exists(redis_key)
|
|
sub_ids = redis.smembers(redis_key).map(&:to_i)
|
|
post_ids.merge(sub_ids)
|
|
else
|
|
PopulateSavedSearchJob.perform_later(query)
|
|
end
|
|
end
|
|
post_ids.to_a.sort.last(QUERY_LIMIT)
|
|
end
|
|
end
|
|
|
|
def refreshed_at
|
|
ttl = SavedSearch.redis.ttl("search:#{normalized_query}")
|
|
return nil if ttl < 0
|
|
(REDIS_EXPIRY.to_i - ttl).seconds.ago
|
|
end
|
|
memoize :refreshed_at
|
|
|
|
def cached_size
|
|
SavedSearch.redis.scard("search:#{normalized_query}")
|
|
end
|
|
memoize :cached_size
|
|
end
|
|
|
|
concerning :Labels do
|
|
class_methods do
|
|
def normalize_label(label)
|
|
label
|
|
.to_s
|
|
.strip
|
|
.downcase
|
|
.gsub(/[[:space:]]/, "_")
|
|
end
|
|
|
|
def search_labels(user_id, params)
|
|
labels = labels_for(user_id)
|
|
|
|
if params[:label].present?
|
|
query = Regexp.escape(params[:label]).gsub("\\*", ".*")
|
|
query = ".*#{query}.*" unless query.include?("*")
|
|
query = /\A#{query}\z/
|
|
labels = labels.grep(query)
|
|
end
|
|
|
|
labels
|
|
end
|
|
|
|
def labels_for(user_id)
|
|
SavedSearch
|
|
.where(user_id: user_id)
|
|
.order("label")
|
|
.pluck(Arel.sql("distinct unnest(labels) as label"))
|
|
end
|
|
end
|
|
|
|
def normalize_labels
|
|
self.labels = labels.map {|x| SavedSearch.normalize_label(x)}.reject(&:blank?)
|
|
end
|
|
|
|
def label_string
|
|
labels.join(" ")
|
|
end
|
|
|
|
def label_string=(val)
|
|
self.labels = val.to_s.split(/[[:space:]]+/)
|
|
end
|
|
|
|
def labels=(labels)
|
|
labels = labels.map { |label| SavedSearch.normalize_label(label) }
|
|
super(labels)
|
|
end
|
|
end
|
|
|
|
concerning :Search do
|
|
class_methods do
|
|
def search(params)
|
|
q = super
|
|
q = q.search_attributes(params, :query)
|
|
|
|
if params[:label]
|
|
q = q.labeled(params[:label])
|
|
end
|
|
|
|
case params[:order]
|
|
when "query"
|
|
q = q.order(:query).order(id: :desc)
|
|
when "label"
|
|
q = q.order(:labels).order(id: :desc)
|
|
else
|
|
q = q.apply_default_order(params)
|
|
end
|
|
|
|
q
|
|
end
|
|
|
|
def populate(query, timeout: 10_000)
|
|
redis_key = "search:#{query}"
|
|
return if redis.exists(redis_key)
|
|
|
|
post_ids = Post.with_timeout(timeout, [], query: query) do
|
|
Post.system_tag_match(query).limit(QUERY_LIMIT).pluck(:id)
|
|
end
|
|
|
|
if post_ids.present?
|
|
redis.sadd(redis_key, post_ids)
|
|
redis.expire(redis_key, REDIS_EXPIRY.to_i)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :Queries do
|
|
class_methods do
|
|
def queries_for(user_id, label: nil, options: {})
|
|
searches = SavedSearch.where(user_id: user_id)
|
|
searches = searches.labeled(label) if label.present?
|
|
queries = searches.map(&:normalized_query)
|
|
queries.sort.uniq
|
|
end
|
|
end
|
|
|
|
def normalized_query
|
|
PostQueryBuilder.new(query).normalized_query.to_s
|
|
end
|
|
|
|
def normalize_query
|
|
self.query = PostQueryBuilder.new(query).normalized_query(sort: false).to_s
|
|
end
|
|
end
|
|
|
|
attr_reader :disable_labels
|
|
belongs_to :user
|
|
validates :query, presence: true
|
|
validate :validate_count
|
|
before_validation :normalize_query
|
|
before_validation :normalize_labels
|
|
scope :labeled, ->(label) { where_array_includes_any_lower(:labels, [normalize_label(label)]) }
|
|
|
|
def validate_count
|
|
if user.saved_searches.count + 1 > user.max_saved_searches
|
|
self.errors[:user] << "can only have up to #{user.max_saved_searches} " + "saved search".pluralize(user.max_saved_searches)
|
|
end
|
|
end
|
|
|
|
def disable_labels=(value)
|
|
user.update(disable_categorized_saved_searches: true) if value.to_s.truthy?
|
|
end
|
|
|
|
def self.available_includes
|
|
[:user]
|
|
end
|
|
end
|