diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 19fe9a228..f9f2a543d 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -25,7 +25,7 @@ module PostsHelper return unless post_search_counts_enabled? return unless params[:action] == "index" && params[:page].nil? && params[:tags].present? - tags = PostQueryBuilder.normalize_query(params[:tags]) + tags = PostQueryBuilder.new(params[:tags]).normalize_query sig = generate_reportbooru_signature("ps-#{tags}") render "posts/partials/index/search_count", sig: sig end @@ -63,7 +63,7 @@ module PostsHelper end def show_tag_change_notice? - CurrentUser.user.is_member? && PostQueryBuilder.scan_query(params[:tags]).size == 1 && TagChangeNoticeService.get_forum_topic_id(params[:tags]) + CurrentUser.user.is_member? && PostQueryBuilder.new(params[:tags]).split_query.size == 1 && TagChangeNoticeService.get_forum_topic_id(params[:tags]) end private diff --git a/app/jobs/tag_batch_change_job.rb b/app/jobs/tag_batch_change_job.rb index f2eda81b4..fb558d250 100644 --- a/app/jobs/tag_batch_change_job.rb +++ b/app/jobs/tag_batch_change_job.rb @@ -6,8 +6,8 @@ class TagBatchChangeJob < ApplicationJob def perform(antecedent, consequent, updater, updater_ip_addr) raise Error.new("antecedent is missing") if antecedent.blank? - normalized_antecedent = TagAlias.to_aliased(PostQueryBuilder.split_query(antecedent.mb_chars.downcase)) - normalized_consequent = TagAlias.to_aliased(PostQueryBuilder.split_query(consequent.mb_chars.downcase)) + normalized_antecedent = TagAlias.to_aliased(PostQueryBuilder.new(antecedent.mb_chars.downcase).split_query) + normalized_consequent = TagAlias.to_aliased(PostQueryBuilder.new(consequent.mb_chars.downcase).parse_tag_edit) CurrentUser.without_safe_mode do CurrentUser.scoped(updater, updater_ip_addr) do @@ -30,7 +30,7 @@ class TagBatchChangeJob < ApplicationJob end def migrate_saved_searches(normalized_antecedent, normalized_consequent) - tags = PostQueryBuilder.split_query(normalized_antecedent.join(" ")) + tags = PostQueryBuilder.new(normalized_antecedent.join(" ")).split_query # https://www.postgresql.org/docs/current/static/functions-array.html saved_searches = SavedSearch.where("string_to_array(query, ' ') @> ARRAY[?]", tags) @@ -53,7 +53,7 @@ class TagBatchChangeJob < ApplicationJob begin repl = user.blacklisted_tags.split(/\r\n|\r|\n/).map do |line| - list = PostQueryBuilder.split_query(line) + list = PostQueryBuilder.new(line).split_query if (list & query).size != query.size next line diff --git a/app/logical/alias_and_implication_importer.rb b/app/logical/alias_and_implication_importer.rb index 7d7985c77..f4034c04d 100644 --- a/app/logical/alias_and_implication_importer.rb +++ b/app/logical/alias_and_implication_importer.rb @@ -88,8 +88,8 @@ class AliasAndImplicationImporter all when :mass_update - all += PostQueryBuilder.split_query(token[1]) - all += PostQueryBuilder.split_query(token[2]) + all += PostQueryBuilder.new(token[1]).split_query + all += PostQueryBuilder.new(token[2]).parse_tag_edit all when :change_category diff --git a/app/logical/post_query_builder.rb b/app/logical/post_query_builder.rb index b428a7c50..ba0d6e027 100644 --- a/app/logical/post_query_builder.rb +++ b/app/logical/post_query_builder.rb @@ -1,6 +1,8 @@ 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 @@ -93,7 +95,7 @@ class PostQueryBuilder def attribute_matches(values, field, type = :integer) values.to_a.reduce(Post.all) do |relation, value| - operator, *args = PostQueryBuilder.parse_metatag_value(value, type) + operator, *args = parse_metatag_value(value, type) relation.where_operator(field, operator, *args) end end @@ -328,7 +330,7 @@ class PostQueryBuilder end def build - q = PostQueryBuilder.parse_query(query_string) + q = parse_query relation = Post.all if q[:tag_count].to_i > Danbooru.config.tag_query_limit @@ -608,13 +610,13 @@ class PostQueryBuilder if q[:order] == "custom" && q[:post_id].present? && q[:post_id][0] == :in relation = relation.find_ordered(q[:post_id][1]) else - relation = PostQueryBuilder.search_order(relation, q[:order]) + relation = search_order(relation, q[:order]) end relation end - def self.search_order(relation, order) + def search_order(relation, order) case order.to_s when "id", "id_asc" relation = relation.order("posts.id ASC") @@ -738,519 +740,519 @@ class PostQueryBuilder end concerning :ParseMethods do - class_methods do - def scan_query(query) - terms = [] - query = query.to_s.gsub(/[[:space:]]/, " ") - scanner = StringScanner.new(query) + def scan_query + terms = [] + query = query_string.to_s.gsub(/[[:space:]]/, " ") + scanner = StringScanner.new(query) - until scanner.eos? - scanner.skip(/ +/) + until scanner.eos? + scanner.skip(/ +/) - if scanner.scan(/(#{METATAGS.join("|")}):/io) - metatag = scanner.captures.first - - if scanner.scan(/"(.+)"/) - value = scanner.captures.first - elsif scanner.scan(/'(.+)'/) - value = scanner.captures.first - else - value = scanner.scan(/[^ ]*/) - end - - terms << OpenStruct.new({ type: :metatag, name: metatag.downcase, value: value }) - elsif scanner.scan(/[^ ]+/) - terms << OpenStruct.new({ type: :tag, value: scanner.matched.downcase }) - end - end - - terms - end - - def split_query(query) - scan_query(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.value - end - end - end - - def normalize_query(query, normalize_aliases: true, sort: true) - tags = split_query(query.to_s) - 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(tag_string) - split_query(tag_string) - end - - def parse_query(query, options = {}) - q = {} - - q[:tag_count] = 0 - - q[:tags] = { - :related => [], - :include => [], - :exclude => [] - } - - scan_query(query).each do |term| - q[:tag_count] += 1 unless Danbooru.config.is_unlimited_tag?(term) - - if term.type == :metatag - g1 = term.name - g2 = term.value - - case g1 - when "-user" - q[:user_neg] ||= [] - q[:user_neg] << g2 - - when "user" - q[:user] ||= [] - q[:user] << g2 - - when "-approver" - q[:approver_neg] ||= [] - q[:approver_neg] << g2 - - when "approver" - q[:approver] ||= [] - q[:approver] << g2 - - when "flagger" - q[:flagger] ||= [] - q[:flagger] << g2 - - when "-flagger" - q[:flagger_neg] ||= [] - q[:flagger_neg] << g2 - - when "appealer" - q[:appealer] ||= [] - q[:appealer] << g2 - - when "-appealer" - q[:appealer_neg] ||= [] - q[:appealer_neg] << g2 - - when "commenter", "comm" - q[:commenter] ||= [] - q[:commenter] << g2 - - when "-commenter", "-comm" - q[:commenter_neg] ||= [] - q[:commenter_neg] << g2 - - when "noter" - q[:noter] ||= [] - q[:noter] << g2 - - when "-noter" - q[:noter_neg] ||= [] - q[:noter_neg] << g2 - - when "noteupdater" - q[:note_updater] ||= [] - q[:note_updater] << g2 - - when "-noteupdater" - q[:note_updater_neg] ||= [] - q[:note_updater_neg] << g2 - - when "-commentaryupdater", "-artcomm" - q[:commentary_updater_neg] ||= [] - q[:commentary_updater_neg] << g2 - - when "commentaryupdater", "artcomm" - q[:commentary_updater] ||= [] - q[:commentary_updater] << g2 - - when "disapproved" - q[:disapproved] ||= [] - q[:disapproved] << g2 - - when "-disapproved" - q[:disapproved_neg] ||= [] - q[:disapproved_neg] << g2 - - when "-pool" - q[:pool_neg] ||= [] - q[:pool_neg] << g2 - - when "pool" - q[:pool] ||= [] - q[:pool] << g2 - - when "ordpool" - q[:ordpool] ||= [] - q[:ordpool] << g2 - - when "-favgroup" - q[:favgroup_neg] ||= [] - q[:favgroup_neg] << g2 - - when "favgroup" - q[:favgroup] ||= [] - q[:favgroup] << g2 - - when "-fav", "-ordfav" - q[:fav_neg] ||= [] - q[:fav_neg] << g2 - - when "fav" - q[:fav] ||= [] - q[:fav] << g2 - - when "ordfav" - q[:ordfav] ||= [] - q[:ordfav] << g2 - - when "-commentary" - q[:commentary_neg] ||= [] - q[:commentary_neg] << g2 - - when "commentary" - q[:commentary] ||= [] - q[:commentary] << g2 - - when "-search" - q[:saved_searches_neg] ||= [] - q[:saved_searches_neg] << g2 - - when "search" - q[:saved_searches] ||= [] - q[:saved_searches] << g2 - - when "md5" - q[:md5] ||= [] - q[:md5] << g2 - - when "-rating" - q[:rating_neg] ||= [] - q[:rating_neg] << g2 - - when "rating" - q[:rating] ||= [] - q[:rating] << g2 - - when "-locked" - q[:locked_neg] ||= [] - q[:locked_neg] << g2 - - when "locked" - q[:locked] ||= [] - q[:locked] << g2 - - when "id" - q[:id] ||= [] - q[:id] << g2 - - when "-id" - q[:id_neg] ||= [] - q[:id_neg] << g2 - - when "width" - q[:width] ||= [] - q[:width] << g2 - - when "height" - q[:height] ||= [] - q[:height] << g2 - - when "mpixels" - q[:mpixels] ||= [] - q[:mpixels] << g2 - - when "ratio" - q[:ratio] ||= [] - q[:ratio] << g2 - - when "score" - q[:score] ||= [] - q[:score] << g2 - - when "favcount" - q[:fav_count] ||= [] - q[:fav_count] << g2 - - when "filesize" - q[:file_size] ||= [] - q[:file_size] << g2 - - when "source" - q[:source] ||= [] - q[:source] << g2 - - when "-source" - q[:source_neg] ||= [] - q[:source_neg] << g2 - - when "date" - q[:date] ||= [] - q[:date] << g2 - - when "age" - q[:age] ||= [] - q[:age] << g2 - - when "tagcount" - q[:post_tag_count] ||= [] - q[:post_tag_count] << g2 - - when /(#{TagCategory.short_name_regex})tags/ - q["#{TagCategory.short_name_mapping[$1]}_tag_count".to_sym] ||= [] - q["#{TagCategory.short_name_mapping[$1]}_tag_count".to_sym] << g2 - - when "parent" - q[:parent] ||= [] - q[:parent] << g2 - - when "-parent" - q[:parent_neg] ||= [] - q[:parent_neg] << g2 - - when "child" - q[:child] ||= [] - q[:child] << g2 - - when "-child" - q[:child_neg] ||= [] - q[:child_neg] << g2 - - when "order" - g2 = g2.downcase - - order, suffix, _tail = g2.partition(/_(asc|desc)\z/i) - if order.in?(COUNT_METATAG_SYNONYMS) - g2 = order.singularize + "_count" + suffix - end - - q[:order] = g2 - - when "limit" - # Do nothing. The controller takes care of it. - - when "-status" - q[:status_neg] ||= [] - q[:status_neg] << g2 - - when "status" - q[:status] ||= [] - q[:status] << g2 - - when "embedded" - q[:embedded] ||= [] - q[:embedded] << g2 - - when "-embedded" - q[:embedded_neg] ||= [] - q[:embedded_neg] << g2 - - when "filetype" - q[:filetype] ||= [] - q[:filetype] << g2 - - when "-filetype" - q[:filetype_neg] ||= [] - q[:filetype_neg] << g2 - - when "pixiv_id", "pixiv" - q[:pixiv_id] ||= [] - q[:pixiv_id] << g2 - - when "-upvote" - q[:upvoter_neg] ||= [] - q[:upvoter_neg] << g2 - - when "upvote" - q[:upvoter] ||= [] - q[:upvoter] << g2 - - when "-downvote" - q[:downvoter_neg] ||= [] - q[:downvoter_neg] << g2 - - when "downvote" - q[:downvoter] ||= [] - q[:downvoter] << g2 - - when *COUNT_METATAGS - q[g1.to_sym] ||= [] - q[g1.to_sym] << g2 - - when *COUNT_METATAG_SYNONYMS - g1 = "#{g1.singularize}_count" - q[g1.to_sym] ||= [] - q[g1.to_sym] << g2 - - end + if scanner.scan(/(#{METATAGS.join("|")}):/io) + metatag = scanner.captures.first + if scanner.scan(/"(.+)"/) + value = scanner.captures.first + elsif scanner.scan(/'(.+)'/) + value = scanner.captures.first else - parse_tag(term.value, q[:tags]) - end - end - - q[:tags][:exclude] = TagAlias.to_aliased(q[:tags][:exclude]) - q[:tags][:include] = TagAlias.to_aliased(q[:tags][:include]) - q[:tags][:related] = TagAlias.to_aliased(q[:tags][:related]) - - return q - end - - def parse_tag_operator(tag) - tag = Tag.normalize_name(tag) - - if tag.starts_with?("-") - ["-", tag.delete_prefix("-")] - elsif tag.starts_with?("~") - ["~", tag.delete_prefix("~")] - else - [nil, tag] - end - end - - def parse_tag(tag, output) - operator, tag = parse_tag_operator(tag) - - if tag.blank? - # XXX ignore "-", "~" operators without a tag. - elsif tag.include?("*") - tags = Tag.wildcard_matches(tag) - - if operator == "-" - output[:exclude] += tags - else - tags = ["~no_matches~"] if tags.empty? # force empty results if wildcard found no matches. - output[:include] += tags - end - elsif operator == "-" - output[:exclude] << tag - elsif operator == "~" - output[:include] << tag - else - output[:related] << tag - end - 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) + value = scanner.scan(/[^ ]*/) 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 + terms << OpenStruct.new({ type: :metatag, name: metatag.downcase, value: value }) + elsif scanner.scan(/[^ ]+/) + terms << OpenStruct.new({ type: :tag, value: scanner.matched.downcase }) 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 + terms + 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 + 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.value end end end + + def normalize_query(normalize_aliases: true, 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_query + q = {} + + q[:tag_count] = 0 + + q[:tags] = { + :related => [], + :include => [], + :exclude => [] + } + + scan_query.each do |term| + q[:tag_count] += 1 unless Danbooru.config.is_unlimited_tag?(term) + + if term.type == :metatag + g1 = term.name + g2 = term.value + + case g1 + when "-user" + q[:user_neg] ||= [] + q[:user_neg] << g2 + + when "user" + q[:user] ||= [] + q[:user] << g2 + + when "-approver" + q[:approver_neg] ||= [] + q[:approver_neg] << g2 + + when "approver" + q[:approver] ||= [] + q[:approver] << g2 + + when "flagger" + q[:flagger] ||= [] + q[:flagger] << g2 + + when "-flagger" + q[:flagger_neg] ||= [] + q[:flagger_neg] << g2 + + when "appealer" + q[:appealer] ||= [] + q[:appealer] << g2 + + when "-appealer" + q[:appealer_neg] ||= [] + q[:appealer_neg] << g2 + + when "commenter", "comm" + q[:commenter] ||= [] + q[:commenter] << g2 + + when "-commenter", "-comm" + q[:commenter_neg] ||= [] + q[:commenter_neg] << g2 + + when "noter" + q[:noter] ||= [] + q[:noter] << g2 + + when "-noter" + q[:noter_neg] ||= [] + q[:noter_neg] << g2 + + when "noteupdater" + q[:note_updater] ||= [] + q[:note_updater] << g2 + + when "-noteupdater" + q[:note_updater_neg] ||= [] + q[:note_updater_neg] << g2 + + when "-commentaryupdater", "-artcomm" + q[:commentary_updater_neg] ||= [] + q[:commentary_updater_neg] << g2 + + when "commentaryupdater", "artcomm" + q[:commentary_updater] ||= [] + q[:commentary_updater] << g2 + + when "disapproved" + q[:disapproved] ||= [] + q[:disapproved] << g2 + + when "-disapproved" + q[:disapproved_neg] ||= [] + q[:disapproved_neg] << g2 + + when "-pool" + q[:pool_neg] ||= [] + q[:pool_neg] << g2 + + when "pool" + q[:pool] ||= [] + q[:pool] << g2 + + when "ordpool" + q[:ordpool] ||= [] + q[:ordpool] << g2 + + when "-favgroup" + q[:favgroup_neg] ||= [] + q[:favgroup_neg] << g2 + + when "favgroup" + q[:favgroup] ||= [] + q[:favgroup] << g2 + + when "-fav", "-ordfav" + q[:fav_neg] ||= [] + q[:fav_neg] << g2 + + when "fav" + q[:fav] ||= [] + q[:fav] << g2 + + when "ordfav" + q[:ordfav] ||= [] + q[:ordfav] << g2 + + when "-commentary" + q[:commentary_neg] ||= [] + q[:commentary_neg] << g2 + + when "commentary" + q[:commentary] ||= [] + q[:commentary] << g2 + + when "-search" + q[:saved_searches_neg] ||= [] + q[:saved_searches_neg] << g2 + + when "search" + q[:saved_searches] ||= [] + q[:saved_searches] << g2 + + when "md5" + q[:md5] ||= [] + q[:md5] << g2 + + when "-rating" + q[:rating_neg] ||= [] + q[:rating_neg] << g2 + + when "rating" + q[:rating] ||= [] + q[:rating] << g2 + + when "-locked" + q[:locked_neg] ||= [] + q[:locked_neg] << g2 + + when "locked" + q[:locked] ||= [] + q[:locked] << g2 + + when "id" + q[:id] ||= [] + q[:id] << g2 + + when "-id" + q[:id_neg] ||= [] + q[:id_neg] << g2 + + when "width" + q[:width] ||= [] + q[:width] << g2 + + when "height" + q[:height] ||= [] + q[:height] << g2 + + when "mpixels" + q[:mpixels] ||= [] + q[:mpixels] << g2 + + when "ratio" + q[:ratio] ||= [] + q[:ratio] << g2 + + when "score" + q[:score] ||= [] + q[:score] << g2 + + when "favcount" + q[:fav_count] ||= [] + q[:fav_count] << g2 + + when "filesize" + q[:file_size] ||= [] + q[:file_size] << g2 + + when "source" + q[:source] ||= [] + q[:source] << g2 + + when "-source" + q[:source_neg] ||= [] + q[:source_neg] << g2 + + when "date" + q[:date] ||= [] + q[:date] << g2 + + when "age" + q[:age] ||= [] + q[:age] << g2 + + when "tagcount" + q[:post_tag_count] ||= [] + q[:post_tag_count] << g2 + + when /(#{TagCategory.short_name_regex})tags/ + q["#{TagCategory.short_name_mapping[$1]}_tag_count".to_sym] ||= [] + q["#{TagCategory.short_name_mapping[$1]}_tag_count".to_sym] << g2 + + when "parent" + q[:parent] ||= [] + q[:parent] << g2 + + when "-parent" + q[:parent_neg] ||= [] + q[:parent_neg] << g2 + + when "child" + q[:child] ||= [] + q[:child] << g2 + + when "-child" + q[:child_neg] ||= [] + q[:child_neg] << g2 + + when "order" + g2 = g2.downcase + + order, suffix, _tail = g2.partition(/_(asc|desc)\z/i) + if order.in?(COUNT_METATAG_SYNONYMS) + g2 = order.singularize + "_count" + suffix + end + + q[:order] = g2 + + when "limit" + # Do nothing. The controller takes care of it. + + when "-status" + q[:status_neg] ||= [] + q[:status_neg] << g2 + + when "status" + q[:status] ||= [] + q[:status] << g2 + + when "embedded" + q[:embedded] ||= [] + q[:embedded] << g2 + + when "-embedded" + q[:embedded_neg] ||= [] + q[:embedded_neg] << g2 + + when "filetype" + q[:filetype] ||= [] + q[:filetype] << g2 + + when "-filetype" + q[:filetype_neg] ||= [] + q[:filetype_neg] << g2 + + when "pixiv_id", "pixiv" + q[:pixiv_id] ||= [] + q[:pixiv_id] << g2 + + when "-upvote" + q[:upvoter_neg] ||= [] + q[:upvoter_neg] << g2 + + when "upvote" + q[:upvoter] ||= [] + q[:upvoter] << g2 + + when "-downvote" + q[:downvoter_neg] ||= [] + q[:downvoter_neg] << g2 + + when "downvote" + q[:downvoter] ||= [] + q[:downvoter] << g2 + + when *COUNT_METATAGS + q[g1.to_sym] ||= [] + q[g1.to_sym] << g2 + + when *COUNT_METATAG_SYNONYMS + g1 = "#{g1.singularize}_count" + q[g1.to_sym] ||= [] + q[g1.to_sym] << g2 + + end + + else + parse_tag(term.value, q[:tags]) + end + end + + q[:tags][:exclude] = TagAlias.to_aliased(q[:tags][:exclude]) + q[:tags][:include] = TagAlias.to_aliased(q[:tags][:include]) + q[:tags][:related] = TagAlias.to_aliased(q[:tags][:related]) + + return q + end + + def parse_tag_operator(tag) + tag = Tag.normalize_name(tag) + + if tag.starts_with?("-") + ["-", tag.delete_prefix("-")] + elsif tag.starts_with?("~") + ["~", tag.delete_prefix("~")] + else + [nil, tag] + end + end + + def parse_tag(tag, output) + operator, tag = parse_tag_operator(tag) + + if tag.blank? + # XXX ignore "-", "~" operators without a tag. + elsif tag.include?("*") + tags = Tag.wildcard_matches(tag) + + if operator == "-" + output[:exclude] += tags + else + tags = ["~no_matches~"] if tags.empty? # force empty results if wildcard found no matches. + output[:include] += tags + end + elsif operator == "-" + output[:exclude] << tag + elsif operator == "~" + output[:include] << tag + else + output[:related] << tag + end + 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 + + memoize :scan_query, :split_query, :parse_query end diff --git a/app/logical/post_sets/post.rb b/app/logical/post_sets/post.rb index a69ed6127..fc8610852 100644 --- a/app/logical/post_sets/post.rb +++ b/app/logical/post_sets/post.rb @@ -4,7 +4,7 @@ module PostSets attr_reader :tag_array, :page, :raw, :random, :post_count, :format def initialize(tags, page = 1, per_page = nil, raw: false, random: false, format: "html") - @tag_array = PostQueryBuilder.split_query(tags) + @tag_array = PostQueryBuilder.new(tags).split_query @page = page @per_page = per_page @raw = raw.to_s.truthy? diff --git a/app/models/post.rb b/app/models/post.rb index 429148ee0..772170288 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -596,7 +596,7 @@ class Post < ApplicationRecord # If someone else committed changes to this post before we did, # then try to merge the tag changes together. current_tags = tag_string_was.split - new_tags = PostQueryBuilder.parse_tag_edit(tag_string) + new_tags = PostQueryBuilder.new(tag_string).parse_tag_edit old_tags = old_tag_string.split kept_tags = current_tags & new_tags @@ -634,7 +634,7 @@ class Post < ApplicationRecord end def normalize_tags - normalized_tags = PostQueryBuilder.split_query(tag_string) + normalized_tags = PostQueryBuilder.new(tag_string).parse_tag_edit normalized_tags = apply_casesensitive_metatags(normalized_tags) normalized_tags = normalized_tags.map(&:downcase) normalized_tags = filter_metatags(normalized_tags) @@ -1070,7 +1070,7 @@ class Post < ApplicationRecord tags = tags.to_s tags += " rating:s" if CurrentUser.safe_mode? tags += " -status:deleted" if CurrentUser.hide_deleted_posts? && !Tag.has_metatag?(tags, "status", "-status") - tags = PostQueryBuilder.normalize_query(tags) + tags = PostQueryBuilder.new(tags).normalize_query # Optimize some cases. these are just estimates but at these # quantities being off by a few hundred doesn't matter much diff --git a/app/models/saved_search.rb b/app/models/saved_search.rb index 39b4a687e..2bb1ff99e 100644 --- a/app/models/saved_search.rb +++ b/app/models/saved_search.rb @@ -136,17 +136,17 @@ class SavedSearch < ApplicationRecord def queries_for(user_id, label: nil, options: {}) searches = SavedSearch.where(user_id: user_id) searches = searches.labeled(label) if label.present? - queries = searches.pluck(:query).map { |query| PostQueryBuilder.normalize_query(query, sort: true) } + queries = searches.pluck(:query).map { |query| PostQueryBuilder.new(query).normalize_query(sort: true) } queries.sort.uniq end end def normalized_query - PostQueryBuilder.normalize_query(query, sort: true) + PostQueryBuilder.new(query).normalize_query(sort: true) end def normalize_query - self.query = PostQueryBuilder.normalize_query(query, sort: false) + self.query = PostQueryBuilder.new(query).normalize_query(sort: false) end end diff --git a/app/models/tag.rb b/app/models/tag.rb index 268fa5850..d341e84b7 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -234,7 +234,7 @@ class Tag < ApplicationRecord end def is_single_tag?(query) - PostQueryBuilder.scan_query(query).size == 1 + PostQueryBuilder.new(query).split_query.size == 1 end def is_metatag?(tag) @@ -256,7 +256,7 @@ class Tag < ApplicationRecord def has_metatag?(tags, *metatags) return nil if tags.blank? - tags = PostQueryBuilder.split_query(tags.to_str) if tags.respond_to?(:to_str) + tags = PostQueryBuilder.new(tags.to_str).split_query if tags.respond_to?(:to_str) tags.grep(/\A(?:#{metatags.map(&:to_s).join("|")}):(.+)\z/i) { $1 }.first end end diff --git a/test/unit/post_query_builder_test.rb b/test/unit/post_query_builder_test.rb index b62d027f6..cdfc69dbe 100644 --- a/test/unit/post_query_builder_test.rb +++ b/test/unit/post_query_builder_test.rb @@ -854,12 +854,12 @@ class PostQueryBuilderTest < ActiveSupport::TestCase should "work" do create(:tag_alias, antecedent_name: "gray", consequent_name: "grey") - assert_equal("foo", PostQueryBuilder.normalize_query("foo")) - assert_equal("foo", PostQueryBuilder.normalize_query(" foo ")) - assert_equal("foo", PostQueryBuilder.normalize_query("FOO")) - assert_equal("foo", PostQueryBuilder.normalize_query("foo foo")) - assert_equal("grey", PostQueryBuilder.normalize_query("gray")) - assert_equal("aaa bbb", PostQueryBuilder.normalize_query("bbb aaa")) + assert_equal("foo", PostQueryBuilder.new("foo").normalize_query) + assert_equal("foo", PostQueryBuilder.new(" foo ").normalize_query) + assert_equal("foo", PostQueryBuilder.new("FOO").normalize_query) + assert_equal("foo", PostQueryBuilder.new("foo foo").normalize_query) + assert_equal("grey", PostQueryBuilder.new("gray").normalize_query) + assert_equal("aaa bbb", PostQueryBuilder.new("bbb aaa").normalize_query) end end end diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index 12123bdd3..537e27868 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -93,13 +93,13 @@ class TagTest < ActiveSupport::TestCase context "A tag parser" do should "scan a query" do - assert_equal(%w(aaa bbb), PostQueryBuilder.split_query("aaa bbb")) - assert_equal(%w(~aaa -bbb* -bbb*), PostQueryBuilder.split_query("~AAa -BBB* -bbb*")) + assert_equal(%w(aaa bbb), PostQueryBuilder.new("aaa bbb").split_query) + assert_equal(%w(~aaa -bbb* -bbb*), PostQueryBuilder.new("~AAa -BBB* -bbb*").split_query) end should "not strip out valid characters when scanning" do - assert_equal(%w(aaa bbb), PostQueryBuilder.split_query("aaa bbb")) - assert_equal(%w(favgroup:yondemasu_yo,_azazel-san. pool:ichigo_100%), PostQueryBuilder.split_query("favgroup:yondemasu_yo,_azazel-san. pool:ichigo_100%")) + assert_equal(%w(aaa bbb), PostQueryBuilder.new("aaa bbb").split_query) + assert_equal(%w(favgroup:yondemasu_yo,_azazel-san. pool:ichigo_100%), PostQueryBuilder.new("favgroup:yondemasu_yo,_azazel-san. pool:ichigo_100%").split_query) end should "cast values" do