diff --git a/app/models/post.rb b/app/models/post.rb index 8cf9bb0e5..940eb581f 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -108,9 +108,7 @@ class Post < ApplicationRecord ) end - module FileMethods - extend ActiveSupport::Concern - + concerning :FileMethods do def seo_tags presenter.humanized_essential_tag_string.gsub(/[^a-z0-9]+/, "_").gsub(/(?:^_+)|(?:_+$)/, "").gsub(/_{2,}/, "_") end @@ -191,7 +189,7 @@ class Post < ApplicationRecord end end - module ImageMethods + concerning :ImageMethods do def twitter_card_supported? image_width.to_i >= 280 && image_height.to_i >= 150 end @@ -248,7 +246,7 @@ class Post < ApplicationRecord end end - module ApprovalMethods + concerning :ApprovalMethods do def in_modqueue? is_pending? || is_flagged? || is_appealed? end @@ -280,7 +278,7 @@ class Post < ApplicationRecord end end - module PresenterMethods + concerning :PresenterMethods do def presenter @presenter ||= PostPresenter.new(self) end @@ -320,7 +318,7 @@ class Post < ApplicationRecord end end - module TagMethods + concerning :TagMethods do def tag_array tag_string.split end @@ -683,7 +681,7 @@ class Post < ApplicationRecord end end - module FavoriteMethods + concerning :FavoriteMethods do def favorited_by?(user) return false if user.is_anonymous? Favorite.exists?(post: self, user: user) @@ -700,7 +698,7 @@ class Post < ApplicationRecord end end - module PoolMethods + concerning :PoolMethods do def pools Pool.where("pools.post_ids && array[?]", id) end @@ -716,7 +714,7 @@ class Post < ApplicationRecord end end - module VoteMethods + concerning :VoteMethods do def vote!(score, voter) # Ignore vote if user doesn't have permission to vote. return unless Pundit.policy!(voter, PostVote).create? @@ -728,7 +726,7 @@ class Post < ApplicationRecord end end - module ParentMethods + concerning :ParentMethods do # A parent has many children. A child belongs to a parent. # A parent cannot have a parent. # @@ -799,7 +797,7 @@ class Post < ApplicationRecord end end - module DeletionMethods + concerning :DeletionMethods do def expunge! transaction do Post.without_timeout do @@ -850,7 +848,7 @@ class Post < ApplicationRecord end end - module VersionMethods + concerning :VersionMethods do def create_version(force = false) if new_record? || saved_change_to_watched_attributes? || force create_new_version @@ -888,7 +886,7 @@ class Post < ApplicationRecord end end - module NoteMethods + concerning :NoteMethods do def has_notes? last_noted_at.present? end @@ -930,7 +928,7 @@ class Post < ApplicationRecord end end - module ApiMethods + concerning :ApiMethods do def legacy_attributes hash = { "has_comments" => last_commented_at.present?, @@ -973,186 +971,188 @@ class Post < ApplicationRecord end end - module SearchMethods - # Return a set of up to N random posts. May return less if there aren't - # enough posts. - # - # @param n [Integer] The maximum number of posts to return - # @return [ActiveRecord::Relation] - def random(n = 1) - posts = n.times.map do - key = SecureRandom.hex(16) - random_up(key) || random_down(key) - end.compact.uniq + concerning :SearchMethods do + class_methods do + # Return a set of up to N random posts. May return less if there aren't + # enough posts. + # + # @param n [Integer] The maximum number of posts to return + # @return [ActiveRecord::Relation] + def random(n = 1) + posts = n.times.map do + key = SecureRandom.hex(16) + random_up(key) || random_down(key) + end.compact.uniq - reorder(nil).in_order_of(:id, posts.map(&:id)) - end - - def random_up(key) - where("md5 < ?", key).reorder(md5: :desc).first - end - - def random_down(key) - where("md5 >= ?", key).reorder(md5: :asc).first - end - - def sample(query, sample_size) - user_tag_match(query, safe_mode: false, hide_deleted_posts: false).reorder(:md5).limit(sample_size) - end - - # unflattens the tag_string into one tag per row. - def with_unflattened_tags - joins("CROSS JOIN unnest(string_to_array(tag_string, ' ')) AS tag") - end - - def with_comment_stats - relation = left_outer_joins(:comments).group(:id).select("posts.*") - relation = relation.select("COUNT(comments.id) AS comment_count") - relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = TRUE) AS deleted_comment_count") - relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = FALSE) AS active_comment_count") - relation - end - - def with_note_stats - relation = left_outer_joins(:notes).group(:id).select("posts.*") - relation = relation.select("COUNT(notes.id) AS note_count") - relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = TRUE) AS active_note_count") - relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = FALSE) AS deleted_note_count") - relation - end - - def with_flag_stats - relation = left_outer_joins(:flags).group(:id).select("posts.*") - relation.select("COUNT(post_flags.id) AS flag_count") - end - - def with_appeal_stats - relation = left_outer_joins(:appeals).group(:id).select("posts.*") - relation = relation.select("COUNT(post_appeals.id) AS appeal_count") - relation - end - - def with_approval_stats - relation = left_outer_joins(:approvals).group(:id).select("posts.*") - relation = relation.select("COUNT(post_approvals.id) AS approval_count") - relation - end - - def with_replacement_stats - relation = left_outer_joins(:replacements).group(:id).select("posts.*") - relation = relation.select("COUNT(post_replacements.id) AS replacement_count") - relation - end - - def with_child_stats - relation = left_outer_joins(:children).group(:id).select("posts.*") - relation = relation.select("COUNT(children_posts.id) AS child_count") - relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = TRUE) AS deleted_child_count") - relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = FALSE) AS active_child_count") - relation - end - - def with_pool_stats - pool_posts = Pool.joins("CROSS JOIN unnest(post_ids) AS post_id").select(:id, :is_deleted, :category, "post_id") - relation = joins("LEFT OUTER JOIN (#{pool_posts.to_sql}) pools ON pools.post_id = posts.id").group(:id).select("posts.*") - - relation = relation.select("COUNT(pools.id) AS pool_count") - relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = TRUE) AS deleted_pool_count") - relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = FALSE) AS active_pool_count") - relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'series') AS series_pool_count") - relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'collection') AS collection_pool_count") - relation - end - - def with_queued_at - relation = group(:id) - relation = relation.left_outer_joins(:flags, :appeals) - relation = relation.select("posts.*") - relation = relation.select(Arel.sql("MAX(GREATEST(posts.created_at, post_flags.created_at, post_appeals.created_at)) AS queued_at")) - relation - end - - def with_stats(tables) - return all if tables.empty? - - relation = all - tables.each do |table| - relation = relation.send("with_#{table}_stats") + reorder(nil).in_order_of(:id, posts.map(&:id)) end - from(relation.arel.as("posts")) - end - - def available_for_moderation(user, hidden: false) - return none if user.is_anonymous? - - approved_posts = user.post_approvals.select(:post_id) - disapproved_posts = user.post_disapprovals.select(:post_id) - - if hidden.present? - where("posts.uploader_id = ? OR posts.id IN (#{approved_posts.to_sql}) OR posts.id IN (#{disapproved_posts.to_sql})", user.id) - else - where.not(uploader: user).where.not(id: approved_posts).where.not(id: disapproved_posts) - end - end - - def raw_tag_match(tag) - Post.where_array_includes_all("string_to_array(posts.tag_string, ' ')", [tag]) - end - - # Perform a tag search as an anonymous user. No tag limit is enforced. - def anon_tag_match(query) - user_tag_match(query, User.anonymous, tag_limit: nil, safe_mode: false, hide_deleted_posts: false) - end - - # Perform a tag search as the system user, DanbooruBot. The search will - # have moderator-level permissions. No tag limit is enforced. - def system_tag_match(query) - user_tag_match(query, User.system, tag_limit: nil, safe_mode: false, hide_deleted_posts: false) - end - - # Perform a tag search as the current user, or as another user. - # - # @param query [String] the tag search to perform - # @param user [User] the user to perform the search as - # @param tag_limit [Integer] the maximum number of tags allowed per search. - # An exception will be raised if the search has too many tags. - # @param safe_mode [Boolean] if true, automatically add rating:s to the search - # @param hide_deleted_posts [Boolean] if true, automatically add -status:deleted to the search - # @return [ActiveRecord::Relation] the set of resulting posts - def user_tag_match(query, user = CurrentUser.user, tag_limit: user.tag_query_limit, safe_mode: CurrentUser.safe_mode?, hide_deleted_posts: user.hide_deleted_posts?) - post_query = PostQueryBuilder.new(query, user, tag_limit: tag_limit, safe_mode: safe_mode, hide_deleted_posts: hide_deleted_posts) - post_query.normalized_query.build - end - - def search(params) - q = search_attributes( - params, - :id, :created_at, :updated_at, :rating, :source, :pixiv_id, :fav_count, - :score, :up_score, :down_score, :md5, :file_ext, :file_size, :image_width, - :image_height, :tag_count, :has_children, :has_active_children, - :is_pending, :is_flagged, :is_deleted, :is_banned, - :last_comment_bumped_at, :last_commented_at, :last_noted_at, - :uploader_ip_addr, :uploader, :approver, :parent, - :artist_commentary, :flags, :appeals, :notes, :comments, :children, - :approvals, :replacements, :pixiv_ugoira_frame_data - ) - - if params[:tags].present? - q = q.user_tag_match(params[:tags]) + def random_up(key) + where("md5 < ?", key).reorder(md5: :desc).first end - if params[:order].present? - q = PostQueryBuilder.new(nil).search_order(q, params[:order]) - else - q = q.apply_default_order(params) + def random_down(key) + where("md5 >= ?", key).reorder(md5: :asc).first end - q + def sample(query, sample_size) + user_tag_match(query, safe_mode: false, hide_deleted_posts: false).reorder(:md5).limit(sample_size) + end + + # unflattens the tag_string into one tag per row. + def with_unflattened_tags + joins("CROSS JOIN unnest(string_to_array(tag_string, ' ')) AS tag") + end + + def with_comment_stats + relation = left_outer_joins(:comments).group(:id).select("posts.*") + relation = relation.select("COUNT(comments.id) AS comment_count") + relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = TRUE) AS deleted_comment_count") + relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = FALSE) AS active_comment_count") + relation + end + + def with_note_stats + relation = left_outer_joins(:notes).group(:id).select("posts.*") + relation = relation.select("COUNT(notes.id) AS note_count") + relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = TRUE) AS active_note_count") + relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = FALSE) AS deleted_note_count") + relation + end + + def with_flag_stats + relation = left_outer_joins(:flags).group(:id).select("posts.*") + relation.select("COUNT(post_flags.id) AS flag_count") + end + + def with_appeal_stats + relation = left_outer_joins(:appeals).group(:id).select("posts.*") + relation = relation.select("COUNT(post_appeals.id) AS appeal_count") + relation + end + + def with_approval_stats + relation = left_outer_joins(:approvals).group(:id).select("posts.*") + relation = relation.select("COUNT(post_approvals.id) AS approval_count") + relation + end + + def with_replacement_stats + relation = left_outer_joins(:replacements).group(:id).select("posts.*") + relation = relation.select("COUNT(post_replacements.id) AS replacement_count") + relation + end + + def with_child_stats + relation = left_outer_joins(:children).group(:id).select("posts.*") + relation = relation.select("COUNT(children_posts.id) AS child_count") + relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = TRUE) AS deleted_child_count") + relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = FALSE) AS active_child_count") + relation + end + + def with_pool_stats + pool_posts = Pool.joins("CROSS JOIN unnest(post_ids) AS post_id").select(:id, :is_deleted, :category, "post_id") + relation = joins("LEFT OUTER JOIN (#{pool_posts.to_sql}) pools ON pools.post_id = posts.id").group(:id).select("posts.*") + + relation = relation.select("COUNT(pools.id) AS pool_count") + relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = TRUE) AS deleted_pool_count") + relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = FALSE) AS active_pool_count") + relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'series') AS series_pool_count") + relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'collection') AS collection_pool_count") + relation + end + + def with_queued_at + relation = group(:id) + relation = relation.left_outer_joins(:flags, :appeals) + relation = relation.select("posts.*") + relation = relation.select(Arel.sql("MAX(GREATEST(posts.created_at, post_flags.created_at, post_appeals.created_at)) AS queued_at")) + relation + end + + def with_stats(tables) + return all if tables.empty? + + relation = all + tables.each do |table| + relation = relation.send("with_#{table}_stats") + end + + from(relation.arel.as("posts")) + end + + def available_for_moderation(user, hidden: false) + return none if user.is_anonymous? + + approved_posts = user.post_approvals.select(:post_id) + disapproved_posts = user.post_disapprovals.select(:post_id) + + if hidden.present? + where("posts.uploader_id = ? OR posts.id IN (#{approved_posts.to_sql}) OR posts.id IN (#{disapproved_posts.to_sql})", user.id) + else + where.not(uploader: user).where.not(id: approved_posts).where.not(id: disapproved_posts) + end + end + + def raw_tag_match(tag) + Post.where_array_includes_all("string_to_array(posts.tag_string, ' ')", [tag]) + end + + # Perform a tag search as an anonymous user. No tag limit is enforced. + def anon_tag_match(query) + user_tag_match(query, User.anonymous, tag_limit: nil, safe_mode: false, hide_deleted_posts: false) + end + + # Perform a tag search as the system user, DanbooruBot. The search will + # have moderator-level permissions. No tag limit is enforced. + def system_tag_match(query) + user_tag_match(query, User.system, tag_limit: nil, safe_mode: false, hide_deleted_posts: false) + end + + # Perform a tag search as the current user, or as another user. + # + # @param query [String] the tag search to perform + # @param user [User] the user to perform the search as + # @param tag_limit [Integer] the maximum number of tags allowed per search. + # An exception will be raised if the search has too many tags. + # @param safe_mode [Boolean] if true, automatically add rating:s to the search + # @param hide_deleted_posts [Boolean] if true, automatically add -status:deleted to the search + # @return [ActiveRecord::Relation] the set of resulting posts + def user_tag_match(query, user = CurrentUser.user, tag_limit: user.tag_query_limit, safe_mode: CurrentUser.safe_mode?, hide_deleted_posts: user.hide_deleted_posts?) + post_query = PostQueryBuilder.new(query, user, tag_limit: tag_limit, safe_mode: safe_mode, hide_deleted_posts: hide_deleted_posts) + post_query.normalized_query.build + end + + def search(params) + q = search_attributes( + params, + :id, :created_at, :updated_at, :rating, :source, :pixiv_id, :fav_count, + :score, :up_score, :down_score, :md5, :file_ext, :file_size, :image_width, + :image_height, :tag_count, :has_children, :has_active_children, + :is_pending, :is_flagged, :is_deleted, :is_banned, + :last_comment_bumped_at, :last_commented_at, :last_noted_at, + :uploader_ip_addr, :uploader, :approver, :parent, + :artist_commentary, :flags, :appeals, :notes, :comments, :children, + :approvals, :replacements, :pixiv_ugoira_frame_data + ) + + if params[:tags].present? + q = q.user_tag_match(params[:tags]) + end + + if params[:order].present? + q = PostQueryBuilder.new(nil).search_order(q, params[:order]) + else + q = q.apply_default_order(params) + end + + q + end end end - module PixivMethods + concerning :PixivMethods do def parse_pixiv_id self.pixiv_id = nil return unless web_source? @@ -1221,7 +1221,7 @@ class Post < ApplicationRecord end end - module ValidationMethods + concerning :ValidationMethods do def post_is_not_its_own_parent if !new_record? && id == parent_id errors.add(:base, "Post cannot have itself as a parent") @@ -1287,23 +1287,6 @@ class Post < ApplicationRecord end end - include FileMethods - include ImageMethods - include ApprovalMethods - include PresenterMethods - include TagMethods - include FavoriteMethods - include PoolMethods - include VoteMethods - include ParentMethods - include DeletionMethods - include VersionMethods - include NoteMethods - include ApiMethods - extend SearchMethods - include PixivMethods - include ValidationMethods - has_bit_flags ["has_embedded_notes"] def safeblocked?