class PostUrlSanitisationRule < ApplicationRecord include Discard::Model class UrlConflictError < StandardError attr_reader :conflicts def initialize(conflicts) @conflicts = conflicts urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ') super("post URL sanitisation conflicts detected for #{ urls }") end end self.primary_key = :priority default_scope -> { kept } validates :source_pattern, presence: true, uniqueness: true validate :source_pattern_must_be_regexp class << self def sanitise(url) = rules.reduce(url.dup) { |value, (pattern, replacement)| value.sub(pattern, replacement) } def apply! rewrites = Post.order(:id).pluck(:id, :url).map do |post_id, original_url| { post_id:, original_url:, sanitised_url: sanitise(original_url) } end conflicts = rewrites .group_by { _1.fetch(:sanitised_url) } .values .filter { _1.size > 1 } .flatten .map { { url: _1.fetch(:sanitised_url), post_id: _1.fetch(:post_id), original_url: _1.fetch(:original_url) } } raise UrlConflictError.new(conflicts) if conflicts.present? changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) } return if changed.empty? token = SecureRandom.hex(6) Post.transaction do changed.each do |row| Post.where(id: row.fetch(:post_id)) .update_all(url: temporary_url_for(row.fetch(:post_id), token)) end changed.each do |row| Post.where(id: row.fetch(:post_id)) .update_all(url: row.fetch(:sanitised_url)) end end end private def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] } def temporary_url_for(post_id, token) = "https://post-url-sanitising.invalid/#{ token }/#{ post_id }" end private def source_pattern_must_be_regexp Regexp.new(source_pattern) rescue RegexpError errors.add :source_pattern, '変な正規表現だね〜(笑)' end end