require "strscan" class PostQueryBuilder extend Memoist COUNT_METATAGS = %w[ comment_count deleted_comment_count active_comment_count note_count deleted_note_count active_note_count flag_count resolved_flag_count unresolved_flag_count child_count deleted_child_count active_child_count pool_count deleted_pool_count active_pool_count series_pool_count collection_pool_count appeal_count approval_count replacement_count ] # allow e.g. `deleted_comments` as a synonym for `deleted_comment_count` COUNT_METATAG_SYNONYMS = COUNT_METATAGS.map { |str| str.delete_suffix("_count").pluralize } # gentags, arttags, copytags, chartags, metatags CATEGORY_COUNT_METATAGS = TagCategory.short_name_list.map { |category| "#{category}tags" } METATAGS = %w[ -user user -approver approver -commenter commenter comm -noter noter -noteupdater noteupdater -artcomm artcomm -commentaryupdater commentaryupdater -flagger flagger -appealer appealer -upvote upvote -downvote downvote -fav fav -ordfav ordfav -favgroup favgroup ordfavgroup -pool pool ordpool -commentary commentary -id id -rating rating -locked locked -source source -status status -filetype filetype -disapproved disapproved -parent parent -child child -search search -embedded embedded md5 width height mpixels ratio score favcount filesize date age order limit tagcount pixiv_id pixiv ] + COUNT_METATAGS + COUNT_METATAG_SYNONYMS + CATEGORY_COUNT_METATAGS ORDER_METATAGS = %w[ id id_desc score score_asc favcount favcount_asc created_at created_at_asc change change_asc comment comment_asc comment_bumped comment_bumped_asc note note_asc artcomm artcomm_asc mpixels mpixels_asc portrait landscape filesize filesize_asc tagcount tagcount_asc rank curated modqueue random custom ] + COUNT_METATAGS + COUNT_METATAG_SYNONYMS.flat_map { |str| [str, "#{str}_asc"] } + CATEGORY_COUNT_METATAGS.flat_map { |str| [str, "#{str}_asc"] } attr_accessor :query_string def initialize(query_string) @query_string = query_string end def tags_match(tags, relation) tsquery = [] negated_wildcard_tags, negated_tags = tags.select(&:negated).partition(&:wildcard) optional_wildcard_tags, optional_tags = tags.select(&:optional).partition(&:wildcard) required_wildcard_tags, required_tags = tags.reject(&:negated).reject(&:optional).partition(&:wildcard) negated_tags = TagAlias.to_aliased(negated_tags.map(&:name)) optional_tags = TagAlias.to_aliased(optional_tags.map(&:name)) required_tags = TagAlias.to_aliased(required_tags.map(&:name)) negated_tags += negated_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } optional_tags += optional_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } optional_tags += required_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } tsquery << "!(#{negated_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if negated_tags.present? tsquery << "(#{optional_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if optional_tags.present? tsquery << "(#{required_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" & ")})" if required_tags.present? return relation if tsquery.empty? relation.where("posts.tag_index @@ to_tsquery('danbooru', E?)", tsquery.join(" & ")) end def metatags_match(metatags, relation) metatags.each do |metatag| relation = relation.merge(metatag_matches(metatag.name, metatag.value, quoted: metatag.quoted)) end relation end def metatag_matches(name, value, quoted: false) case name when "id" attribute_matches(value, :id) when "-id" Post.where.not(id: value.to_i) when "md5" attribute_matches(value, :md5, :md5) when "width" attribute_matches(value, :image_width) when "height" attribute_matches(value, :image_height) when "mpixels" attribute_matches(value, "posts.image_width * posts.image_height / 1000000.0", :float) when "ratio" attribute_matches(value, "ROUND(1.0 * posts.image_width / GREATEST(1, posts.image_height), 2)", :ratio) when "score" attribute_matches(value, :score) when "favcount" attribute_matches(value, :fav_count) when "filesize" attribute_matches(value, :file_size, :filesize) when "filetype" attribute_matches(value, :file_ext, :enum) when "-filetype" attribute_matches(value, :file_ext, :enum).negate(:nor) when "date" attribute_matches(value, :created_at, :date) when "age" attribute_matches(value, :created_at, :age) when "pixiv", "pixiv_id" attribute_matches(value, :pixiv_id) when "tagcount" attribute_matches(value, :tag_count) when "status" status_matches(value) when "-status" status_matches(value).negate when "parent" parent_matches(value) when "-parent" parent_matches(value).negate when "child" child_matches(value) when "-child" child_matches(value).negate when "rating" Post.where(rating: value.first.downcase) when "-rating" Post.where(rating: value.first.downcase) when "locked" locked_matches(value) when "-locked" locked_matches(value).negate when "embedded" embedded_matches(value) when "-embedded" embedded_matches(value).negate when "source" source_matches(value, quoted) when "-source" source_matches(value, quoted).negate when "disapproved" disapproved_matches(value) when "-disapproved" disapproved_matches(value).negate when "commentary" commentary_matches(value, quoted) when "-commentary" commentary_matches(value, quoted).negate when "search" saved_search_matches(value) when "-search" saved_search_matches(value).negate when "pool" pool_matches(value) when "-pool" pool_matches(value).negate when "ordpool" ordpool_matches(value) when "favgroup" favgroup_matches(value) when "-favgroup" favgroup_matches(value).negate when "ordfavgroup" ordfavgroup_matches(value) when "fav" favorites_include(value) when "-fav" favorites_exclude(value) when "ordfav" ordfav_matches(value) when "user" user_matches(:uploader, value) when "-user" user_matches(:uploader, value).negate when "approver" user_matches(:approver, value) when "-approver" user_matches(:approver, value).negate when "flagger" flagger_matches(value) when "-flagger" flagger_matches(value).negate when "appealer" user_subquery_matches(PostAppeal.unscoped, value) when "-appealer" user_subquery_matches(PostAppeal.unscoped, value).negate when "commenter", "comm" user_subquery_matches(Comment.unscoped, value) when "-commenter" user_subquery_matches(Comment.unscoped, value).negate when "commentaryupdater", "artcomm" user_subquery_matches(ArtistCommentaryVersion.unscoped, value, field: :updater) when "-commentaryupdater", "-artcomm" user_subquery_matches(ArtistCommentaryVersion.unscoped, value, field: :updater).negate when "noter" user_subquery_matches(NoteVersion.unscoped.where(version: 1), value, field: :updater) when "-noter" user_subquery_matches(NoteVersion.unscoped.where(version: 1), value, field: :updater).negate when "noteupdater" user_subquery_matches(NoteVersion.unscoped, value, field: :updater).negate when "-noteupdater" user_subquery_matches(NoteVersion.unscoped, value, field: :updater) when "upvoter", "upvote" user_subquery_matches(PostVote.positive.visible(CurrentUser.user), value, field: :user) when "-upvoter", "-upvote" user_subquery_matches(PostVote.positive.visible(CurrentUser.user), value, field: :user).negate when "downvoter", "downvote" user_subquery_matches(PostVote.negative.visible(CurrentUser.user), value, field: :user) when "-downvoter", "-downvote" user_subquery_matches(PostVote.negative.visible(CurrentUser.user), value, field: :user).negate when *CATEGORY_COUNT_METATAGS short_category = name.delete_suffix("tags") category = TagCategory.short_name_mapping[short_category] attribute = "tag_count_#{category}" attribute_matches(value, attribute.to_sym) when *COUNT_METATAGS attribute_matches(value, name.to_sym) when "limit" Post.all when "order" Post.all else raise NotImplementedError, "metatag not implemented" end end def tags_include(*tags) query = tags.map(&:to_escaped_for_tsquery).join(" & ") Post.where("posts.tag_index @@ to_tsquery('danbooru', E?)", query) end def tags_exclude(*tags) query = tags.map(&:to_escaped_for_tsquery).join(" | ") Post.where("posts.tag_index @@ to_tsquery('danbooru', E?)", "!(#{query})") end def attribute_matches(value, field, type = :integer) operator, *args = parse_metatag_value(value, type) Post.where_operator(field, operator, *args) end def user_matches(field, username) case username.downcase when "any" Post.where.not(field => nil) when "none" Post.where(field => nil) else Post.where(field => User.name_matches(username)) end end def user_subquery_matches(subquery, username, field: :creator, &block) subquery = subquery.where("post_id = posts.id").select(1) if username == "any" Post.where("EXISTS (#{subquery.to_sql})") elsif username == "none" Post.where("NOT EXISTS (#{subquery.to_sql})") elsif block.nil? subquery = subquery.where(field => User.name_matches(username)) Post.where("EXISTS (#{subquery.to_sql})") else subquery = subquery.merge(block.call(username)) return Post.none if subquery.to_sql.blank? Post.where("EXISTS (#{subquery.to_sql})") end end def flagger_matches(username) flags = PostFlag.unscoped.category_matches("normal") user_subquery_matches(flags, username) do |username| flagger = User.find_by_name(username) PostFlag.unscoped.creator_matches(flagger, CurrentUser.user) end end def saved_search_matches(label) case label.downcase when "all" Post.where(id: SavedSearch.post_ids_for(CurrentUser.id)) else Post.where(id: SavedSearch.post_ids_for(CurrentUser.id, label: label)) end end def status_matches(status) case status.downcase when "pending" Post.pending when "flagged" Post.flagged when "modqueue" Post.pending_or_flagged when "deleted" Post.deleted when "banned" Post.banned when "active" Post.active when "unmoderated" Post.pending_or_flagged.available_for_moderation when "all", "any" Post.all else Post.none end end def disapproved_matches(query) if query.downcase.in?(PostDisapproval::REASONS) Post.where(disapprovals: PostDisapproval.where(reason: query.downcase)) elsif User.normalize_name(query) == CurrentUser.user.name Post.where(disapprovals: PostDisapproval.where(user: CurrentUser.user)) else Post.none end end def parent_matches(parent) case parent.downcase when "none" Post.where(parent: nil) when "any" Post.where.not(parent: nil) when /\A\d+\z/ Post.where(id: parent).or(Post.where(parent: parent)) else Post.none end end def child_matches(child) case child.downcase when "none" Post.where(has_children: false) when "any" Post.where(has_children: true) else Post.none end end def source_matches(source, quoted = false) case source.downcase in "none" unless quoted Post.where_like(:source, "") else Post.where_ilike(:source, source + "*") end end def embedded_matches(embedded) if embedded.truthy? Post.bit_flags_match(:has_embedded_notes, true) elsif embedded.falsy? Post.bit_flags_match(:has_embedded_notes, false) else Post.none end end def pool_matches(pool_name) case pool_name.downcase when "none" Post.where.not(id: Pool.select("unnest(post_ids)")) when "any" Post.where(id: Pool.select("unnest(post_ids)")) when "series" Post.where(id: Pool.series.select("unnest(post_ids)")) when "collection" Post.where(id: Pool.collection.select("unnest(post_ids)")) when /\*/ Post.where(id: Pool.name_matches(pool_name).select("unnest(post_ids)")) else Post.where(id: Pool.named(pool_name).select("unnest(post_ids)")) end end def ordpool_matches(pool_name) # XXX unify with Pool#posts pool_posts = Pool.named(pool_name).joins("CROSS JOIN unnest(pools.post_ids) WITH ORDINALITY AS row(post_id, pool_index)").select(:post_id, :pool_index) Post.joins("JOIN (#{pool_posts.to_sql}) pool_posts ON pool_posts.post_id = posts.id").order("pool_posts.pool_index ASC") end def ordfavgroup_matches(query) # XXX unify with FavoriteGroup#posts favgroup = FavoriteGroup.visible(CurrentUser.user).name_or_id_matches(query, CurrentUser.user) favgroup_posts = favgroup.joins("CROSS JOIN unnest(favorite_groups.post_ids) WITH ORDINALITY AS row(post_id, favgroup_index)").select(:post_id, :favgroup_index) Post.joins("JOIN (#{favgroup_posts.to_sql}) favgroup_posts ON favgroup_posts.post_id = posts.id").order("favgroup_posts.favgroup_index ASC") end def favgroup_matches(query) favgroup = FavoriteGroup.visible(CurrentUser.user).name_or_id_matches(query, CurrentUser.user) Post.where(id: favgroup.select("unnest(post_ids)")) end def favorites_include(username) favuser = User.find_by_name(username) if favuser.present? && Pundit.policy!([CurrentUser.user, nil], favuser).can_see_favorites? tags_include("fav:#{favuser.id}") else Post.none end end def favorites_exclude(username) favuser = User.find_by_name(username) if favuser.present? && Pundit.policy!([CurrentUser.user, nil], favuser).can_see_favorites? tags_exclude("fav:#{favuser.id}") else Post.all end end def ordfav_matches(username) user = User.find_by_name(username) favorites_include(username).joins(:favorites).merge(Favorite.for_user(user.id)).order("favorites.id DESC") end def commentary_matches(query, quoted = false) case query.downcase in "none" | "false" unless quoted Post.where.not(artist_commentary: ArtistCommentary.all).or(Post.where(artist_commentary: ArtistCommentary.deleted)) in "any" | "true" unless quoted Post.where(artist_commentary: ArtistCommentary.undeleted) in "translated" unless quoted Post.where(artist_commentary: ArtistCommentary.translated) in "untranslated" unless quoted Post.where(artist_commentary: ArtistCommentary.untranslated) else Post.where(artist_commentary: ArtistCommentary.text_matches(query)) end end def locked_matches(query) case query.downcase when "rating" Post.where(is_rating_locked: true) when "note", "notes" Post.where(is_note_locked: true) when "status" Post.where(is_status_locked: true) else Post.none end end def table_for_metatag(metatag) if metatag.in?(COUNT_METATAGS) metatag[/(?[a-z]+)_count\z/i, :table] else nil end end def tables_for_query metatag_names = metatags.map(&:name) metatag_names << find_metatag(:order).remove(/_(asc|desc)\z/i) if has_metatag?(:order) tables = metatag_names.map { |metatag| table_for_metatag(metatag.to_s) } tables.compact.uniq end def add_joins(relation) tables = tables_for_query relation = relation.with_stats(tables) relation end def hide_deleted_posts? return false if CurrentUser.admin_mode? return false if find_metatag("status").to_s.downcase.in?(%w[deleted active any all]) return false if find_metatag("-status").to_s.downcase.in?(%w[deleted active any all]) return CurrentUser.user.hide_deleted_posts? end def build tag_count = terms.count { |term| !Danbooru.config.is_unlimited_tag?(term) } if tag_count > Danbooru.config.tag_query_limit raise ::Post::SearchError end relation = Post.all relation = relation.where(rating: 's') if CurrentUser.safe_mode? relation = relation.undeleted if hide_deleted_posts? relation = add_joins(relation) relation = metatags_match(metatags, relation) relation = tags_match(tags, relation) # HACK: if we're using a date: or age: metatag, default to ordering by # created_at instead of id so that the query will use the created_at index. if has_metatag?(:date, :age) && find_metatag(:order).in?(["id", "id_asc"]) relation = search_order(relation, "created_at_asc") elsif has_metatag?(:date, :age) && find_metatag(:order).in?(["id_desc", nil]) relation = search_order(relation, "created_at_desc") elsif find_metatag(:order) == "custom" relation = search_order_custom(relation, select_metatags(:id).map(&:value)) else relation = search_order(relation, find_metatag(:order)) end relation end def search_order(relation, order) case order.to_s.downcase when "id", "id_asc" relation = relation.order("posts.id ASC") when "id_desc" relation = relation.order("posts.id DESC") when "score", "score_desc" relation = relation.order("posts.score DESC, posts.id DESC") when "score_asc" relation = relation.order("posts.score ASC, posts.id ASC") when "favcount" relation = relation.order("posts.fav_count DESC, posts.id DESC") when "favcount_asc" relation = relation.order("posts.fav_count ASC, posts.id ASC") when "created_at", "created_at_desc" relation = relation.order("posts.created_at DESC") when "created_at_asc" relation = relation.order("posts.created_at ASC") when "change", "change_desc" relation = relation.order("posts.updated_at DESC, posts.id DESC") when "change_asc" relation = relation.order("posts.updated_at ASC, posts.id ASC") when "comment", "comm" relation = relation.order("posts.last_commented_at DESC NULLS LAST, posts.id DESC") when "comment_asc", "comm_asc" relation = relation.order("posts.last_commented_at ASC NULLS LAST, posts.id ASC") when "comment_bumped" relation = relation.order("posts.last_comment_bumped_at DESC NULLS LAST") when "comment_bumped_asc" relation = relation.order("posts.last_comment_bumped_at ASC NULLS FIRST") when "note" relation = relation.order("posts.last_noted_at DESC NULLS LAST") when "note_asc" relation = relation.order("posts.last_noted_at ASC NULLS FIRST") when "artcomm" relation = relation.joins("INNER JOIN artist_commentaries ON artist_commentaries.post_id = posts.id") relation = relation.order("artist_commentaries.updated_at DESC") when "artcomm_asc" relation = relation.joins("INNER JOIN artist_commentaries ON artist_commentaries.post_id = posts.id") relation = relation.order("artist_commentaries.updated_at ASC") when "mpixels", "mpixels_desc" relation = relation.where(Arel.sql("posts.image_width is not null and posts.image_height is not null")) # Use "w*h/1000000", even though "w*h" would give the same result, so this can use # the posts_mpixels index. relation = relation.order(Arel.sql("posts.image_width * posts.image_height / 1000000.0 DESC")) when "mpixels_asc" relation = relation.where("posts.image_width is not null and posts.image_height is not null") relation = relation.order(Arel.sql("posts.image_width * posts.image_height / 1000000.0 ASC")) when "portrait" relation = relation.where("posts.image_width IS NOT NULL and posts.image_height IS NOT NULL") relation = relation.order(Arel.sql("1.0 * posts.image_width / GREATEST(1, posts.image_height) ASC")) when "landscape" relation = relation.where("posts.image_width IS NOT NULL and posts.image_height IS NOT NULL") relation = relation.order(Arel.sql("1.0 * posts.image_width / GREATEST(1, posts.image_height) DESC")) when "filesize", "filesize_desc" relation = relation.order("posts.file_size DESC") when "filesize_asc" relation = relation.order("posts.file_size ASC") when /\A(?#{COUNT_METATAGS.join("|")})(_(?asc|desc))?\z/i column = $~[:column] direction = $~[:direction] || "desc" relation = relation.order(column => direction, :id => direction) when "tagcount", "tagcount_desc" relation = relation.order("posts.tag_count DESC") when "tagcount_asc" relation = relation.order("posts.tag_count ASC") when /(#{TagCategory.short_name_regex})tags(?:\Z|_desc)/ relation = relation.order("posts.tag_count_#{TagCategory.short_name_mapping[$1]} DESC") when /(#{TagCategory.short_name_regex})tags_asc/ relation = relation.order("posts.tag_count_#{TagCategory.short_name_mapping[$1]} ASC") when "rank" relation = relation.where("posts.score > 0 and posts.created_at >= ?", 2.days.ago) relation = relation.order(Arel.sql("log(3, posts.score) + (extract(epoch from posts.created_at) - extract(epoch from timestamp '2005-05-24')) / 35000 DESC")) when "curated" contributors = User.bit_prefs_match(:can_upload_free, true) relation = relation .joins(:favorites) .where(favorites: { user: contributors }) .group("posts.id") .select("posts.*, COUNT(*) AS contributor_fav_count") .order("contributor_fav_count DESC, posts.fav_count DESC, posts.id DESC") when "modqueue", "modqueue_desc" relation = relation.left_outer_joins(:flags).order(Arel.sql("GREATEST(posts.created_at, post_flags.created_at) DESC, posts.id DESC")) when "modqueue_asc" relation = relation.left_outer_joins(:flags).order(Arel.sql("GREATEST(posts.created_at, post_flags.created_at) ASC, posts.id ASC")) else relation = relation.order("posts.id DESC") end relation end def search_order_custom(relation, id_metatags) return relation.none unless id_metatags.present? && id_metatags.size == 1 operator, ids = parse_range(id_metatags.first, :integer) return relation.none unless operator == :in relation.find_ordered(ids) end concerning :ParseMethods do def scan_query terms = [] query = query_string.to_s.gsub(/[[:space:]]/, " ") scanner = StringScanner.new(query) until scanner.eos? scanner.skip(/ +/) if scanner.scan(/(#{METATAGS.join("|")}):/io) metatag = scanner.captures.first.downcase if scanner.scan(/"(.+)"/) || scanner.scan(/'(.+)'/) value = scanner.captures.first quoted = true else value = scanner.scan(/[^ ]*/) quoted = false end if metatag.in?(COUNT_METATAG_SYNONYMS) metatag = metatag.singularize + "_count" elsif metatag == "order" attribute, direction, _tail = value.to_s.downcase.partition(/_(asc|desc)\z/i) if attribute.in?(COUNT_METATAG_SYNONYMS) value = attribute.singularize + "_count" + direction end end terms << OpenStruct.new({ type: :metatag, name: metatag, value: value, quoted: quoted }) elsif scanner.scan(/([-~])?([^ ]+)/) operator = scanner.captures.first tag = scanner.captures.second terms << OpenStruct.new(type: :tag, name: tag.downcase, negated: (operator == "-"), optional: (operator == "~"), wildcard: tag.include?("*")) elsif scanner.scan(/[^ ]+/) terms << OpenStruct.new(type: :tag, name: scanner.matched.downcase) end end terms end def split_query scan_query.map do |term| if term.type == :metatag && term.value.include?(" ") "#{term.name}:\"#{term.value}\"" elsif term.type == :metatag "#{term.name}:#{term.value}" elsif term.type == :tag && term.negated "-#{term.name}" elsif term.type == :tag && term.optional "~#{term.name}" elsif term.type == :tag term.name end end end def normalize_query(normalize_aliases: false, sort: true) tags = split_query tags = tags.map { |t| Tag.normalize_name(t) } tags = TagAlias.to_aliased(tags) if normalize_aliases tags = tags.sort if sort tags = tags.uniq tags.join(" ") end def parse_tag_edit split_query end def parse_cast(object, type) case type when :enum object.to_s.downcase when :integer object.to_i when :float object.to_f when :md5 object.to_s.downcase when :date, :datetime Time.zone.parse(object) rescue nil when :age DurationParser.parse(object).ago when :ratio object =~ /\A(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)\Z/i if $1 && $2.to_f != 0.0 ($1.to_f / $2.to_f).round(2) else object.to_f.round(2) end when :filesize object =~ /\A(\d+(?:\.\d*)?|\d*\.\d+)([kKmM]?)[bB]?\Z/ size = $1.to_f unit = $2 conversion_factor = case unit when /m/i 1024 * 1024 when /k/i 1024 else 1 end (size * conversion_factor).to_i end end def parse_metatag_value(string, type) if type == :enum [:in, string.split(/[, ]+/).map { |x| parse_cast(x, type) }] else parse_range(string, type) end end def parse_range(string, type) range = case string when /\A(.+?)\.\.\.(.+)/ # A...B lo, hi = [parse_cast($1, type), parse_cast($2, type)].sort [:between, (lo...hi)] when /\A(.+?)\.\.(.+)/ lo, hi = [parse_cast($1, type), parse_cast($2, type)].sort [:between, (lo..hi)] when /\A<=(.+)/, /\A\.\.(.+)/ [:lteq, parse_cast($1, type)] when /\A<(.+)/ [:lt, parse_cast($1, type)] when /\A>=(.+)/, /\A(.+)\.\.\Z/ [:gteq, parse_cast($1, type)] when /\A>(.+)/ [:gt, parse_cast($1, type)] when /[, ]/ [:in, string.split(/[, ]+/).map {|x| parse_cast(x, type)}] when "any" [:not_eq, nil] when "none" [:eq, nil] else # add a 5% tolerance for float and filesize values if type == :float || (type == :filesize && string =~ /[km]b?\z/i) value = parse_cast(string, type) [:between, (value * 0.95..value * 1.05)] elsif type.in?([:date, :age]) value = parse_cast(string, type) [:between, (value.beginning_of_day..value.end_of_day)] else [:eq, parse_cast(string, type)] end end range = reverse_range(range) if type == :age range end def reverse_range(range) case range in [:lteq, value] [:gteq, value] in [:lt, value] [:gt, value] in [:gteq, value] [:lteq, value] in [:gt, value] [:lt, value] else range end end end concerning :UtilityMethods do def terms scan_query end def tags scan_query.select { |term| term.type == :tag } end def metatags scan_query.select { |term| term.type == :metatag } end def select_metatags(metatag) metatags.select { |term| term.name == metatag.to_s.downcase } end def find_metatag(metatag) select_metatags(metatag).first.try(:value) end def has_metatag?(*metatag_names) metatags.any? { |term| term.name.in?(metatag_names.map(&:to_s).map(&:downcase)) } end 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 def is_empty_search? scan_query.size == 0 end def is_single_term? scan_query.size == 1 end def is_single_tag? is_single_term? && tags.size == 1 end def is_simple_tag? tag = tags.first is_single_tag? && !tag.negated && !tag.optional && !tag.wildcard end def is_wildcard_search? is_single_tag? && tags.first.wildcard end end memoize :scan_query, :split_query end