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[/(?