otwarchive-symphonyarchive/app/models/search/bookmarkable_query.rb
2026-03-11 22:22:11 +00:00

287 lines
8.8 KiB
Ruby

class BookmarkableQuery < Query
include TaggableQuery
attr_accessor :bookmark_query
# Rather than compute this information twice, we rely on the BookmarkQuery
# class to calculate information about sorting.
delegate :sort_column, :sort_direction,
to: :bookmark_query
# The "klass" function here returns the class name used to load search
# results. The BookmarkableQuery is unique among Query classes because it can
# return objects from more than one table, so we need to use a special class
# that can handle IDs of multiple types.
def klass
'BookmarkableDecorator'
end
def index_name
BookmarkableIndexer.index_name
end
def document_type
BookmarkableIndexer.document_type
end
# The BookmarkableQuery is unique among queries in that it depends wholly on
# the BookmarkQuery for all of its options. So we have a slightly different
# constructor.
def initialize(bookmark_query)
self.bookmark_query = bookmark_query
@options = bookmark_query.options
end
# Combine the filters and queries for both the bookmark and the bookmarkable.
def filtered_query
make_bool(
# All queries/filters/exclusion filters for the bookmark are wrapped in a
# single has_child query by the bookmark_filter function:
must: bookmark_filter,
# We never sort by score, so we can always ignore the score on our
# queries, grouping them together with our filters. (Note, however, that
# the bookmark search can incorporate our score, so there is a
# distinction between queries and filters -- just not in this function.)
filter: make_list(queries, filters),
must_not: exclusion_filters
)
end
# Queries that apply only to the bookmarkable. Bookmark queries are handled
# in filtered_query, and should not be included here.
def queries
@queries ||= make_list(
general_query
)
end
# Filters that apply only to the bookmarkable. Bookmark filters are handled
# in filtered_query, and should not be included here.
def filters
@filters ||= make_list(
complete_filter,
language_filter,
filter_id_filter,
named_tag_inclusion_filter,
date_filter
)
end
# Exclusion filters that apply only to the bookmarkable. Exclusion filters
# for the bookmark are handled in filtered_query, and should not be included
# here.
def exclusion_filters
@exclusion_filters ||= make_list(
unposted_filter,
hidden_filter,
restricted_filter,
tag_exclusion_filter,
named_tag_exclusion_filter
)
end
####################
# QUERIES
####################
def general_query
return nil if bookmarkable_query_text.blank?
{ query_string: { query: bookmarkable_query_text, default_operator: "AND" } }
end
def bookmarkable_query_text
query_text = (options[:bookmarkable_query] || "").dup
escape_slashes(query_text.strip)
end
####################
# SORTING AND AGGREGATIONS
####################
# When sorting by bookmarkable date, we use the revised_at field to order the
# results. When sorting by created_at, we use _score to sort (because the
# only way to sort by a child's fields is to store the value in the _score
# field and sort by score).
def sort
if sort_column == "bookmarkable_date"
sort_hash = { revised_at: { order: sort_direction, unmapped_type: "date" } }
else
sort_hash = { _score: { order: sort_direction } }
end
[sort_hash, { sort_id: { order: sort_direction } }]
end
# Define the aggregations for just the bookmarkable. This is combined with
# the bookmark's aggregations below.
def bookmarkable_aggregations
aggs = {}
if bookmark_query.facet_tags?
%w[rating archive_warning category fandom character relationship freeform].each do |facet_type|
aggs[facet_type] = {
terms: {
field: "#{facet_type}_ids"
}
}
end
end
aggs
end
# Combine the bookmarkable aggregations with the bookmark aggregations from
# the bookmark query.
def aggregations
aggs = bookmarkable_aggregations
bookmark_aggregations = bookmark_query.bookmark_aggregations
if bookmark_aggregations.present?
aggs[:bookmarks] = {
# Aggregate on our child bookmarks.
children: { type: "bookmark" },
aggs: {
filtered_bookmarks: {
filter: bookmark_bool,
aggs: bookmark_aggregations
}
}
}
end
{ aggs: aggs } if aggs.present?
end
####################
# BOOKMARKS
####################
# Create a single has_child query with ALL of the child's queries and filters
# included. In order to avoid issues with multiple bookmarks combining to
# create an (incorrect) bookmarkable match, there MUST be exactly one
# has_child query. (Plus, it probably makes it faster.)
def bookmark_filter
bool = bookmark_bool
# If we're sorting by created_at, we actually need to fetch the bookmarks'
# created_at as the score of this query, so that we can sort by score (and
# therefore by the bookmarks' created_at).
bool = field_value_score("created_at", bool) if sort_column == "created_at"
{
has_child: {
type: "bookmark",
score_mode: "max",
query: bool,
inner_hits: {
size: inner_hits_size,
sort: { created_at: { order: "desc", unmapped_type: "date" } }
}
}
}
end
# The bool used in the has_child query and to filter the bookmark
# aggregations. Contains all of the constraints on bookmarks, and no
# constraints on bookmarkables.
def bookmark_bool
make_bool(
must: bookmark_query.queries,
filter: bookmark_query.filters,
must_not: bookmark_query.exclusion_filters
)
end
####################
# FILTERS
####################
def complete_filter
term_filter(:complete, 'true') if options[:complete].present?
end
def language_filter
term_filter(:"language_id.keyword", options[:language_id]) if options[:language_id].present?
end
def filter_id_filter
if filter_ids.present?
filter_ids.map { |filter_id| term_filter(:filter_ids, filter_id) }
end
end
# The date filter on the bookmarkable (i.e. when the bookmarkable was last
# updated).
def date_filter
if options[:bookmarkable_date].present?
{ range: { revised_at: SearchRange.parsed(options[:bookmarkable_date]) } }
end
end
# Exclude drafts from bookmarkable search results.
# Note that this is used as an exclusion filter, not an inclusion filter, so
# the boolean is flipped from the way you might expect.
def unposted_filter
term_filter(:posted, 'false')
end
# Exclude items hidden by admin from bookmarkable search results.
# Note that this is used as an exclusion filter, not an inclusion filter, so
# the boolean is flipped from the way you might expect.
def hidden_filter
term_filter(:hidden_by_admin, 'true')
end
# Exclude restricted works/series when the user isn't logged in.
# Note that this is used as an exclusion filter, not an inclusion filter, so
# the boolean is flipped from the way you might expect.
def restricted_filter
term_filter(:restricted, 'true') unless include_restricted?
end
def tag_exclusion_filter
if exclusion_ids.present?
terms_filter(:filter_ids, exclusion_ids)
end
end
# This filter is used to restrict our results to only include bookmarkables
# whose "tag" text matches all of the tag names in included_tag_names. This
# is useful when the user enters a non-existent tag, which would be discarded
# by the TaggableQuery.filter_ids function.
def named_tag_inclusion_filter
return if included_tag_names.blank?
match_filter(:tag, included_tag_names.join(" "))
end
# This set of filters is used to prevent us from matching any bookmarkables
# whose "tag" text matches one of the passed-in tag names. This is useful
# when the user enters a non-existent tag, which would be discarded by the
# TaggableQuery.exclusion_ids function.
#
# Note that we separate these into different filters to get the logic of tag
# exclusion right: if we're excluding "A B" and "C D", we want the query to
# be "not(A and B) and not(C and D)", which can't be accomplished in a single
# match query.
def named_tag_exclusion_filter
excluded_tag_names.map do |tag_name|
match_filter(:tag, tag_name)
end
end
####################
# HELPERS
####################
# The number of bookmarks to return with each bookmarkable.
def inner_hits_size
ArchiveConfig.NUMBER_OF_BOOKMARKS_SHOWN_PER_BOOKMARKABLE || 5
end
def include_restricted?
# Use fetch instead of || here to make sure that we don't accidentally
# override a deliberate choice not to show restricted bookmarks.
options.fetch(:show_restricted, User.current_user.present?)
end
end