有任何用于检测孤立记录的 Rails 插件/gems 吗?

发布于 2024-10-10 03:43:51 字数 48 浏览 2 评论 0原文

寻找可以遍历模型中定义的关系并可以检查数据库中的孤立记录/表之间断开的链接的东西。

Looking for something that can go through the relationships defined in models and can check the DB for orphaned records/broken links between tables.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(8

白首有我共你 2024-10-17 03:43:51

(有关以下脚本的最新版本,请参阅 https://gist.github.com/KieranP/3849777< /a>)

Martin 脚本的问题在于它使用 ActiveRecord 首先提取记录,然后查找关联,然后获取关联。它为每个关联生成大量 SQL 调用。对于小型应用程序来说这还不错,但是当您有多个包含 100k 条记录的表并且每个表都有 5 个以上的 own_to 时,它可能需要 10 分钟以上才能完成。

以下脚本使用 SQL,在 Rails 应用程序内的 app/models 中查找所有模型的孤立的 own_to 关联。它处理简单的belongs_to、belongs_to using :class_name 和多态belongs_to 调用。在我使用的生产数据上,Martin 脚本的稍微修改版本的运行时间从 9 分钟减少到仅 8 秒,并且发现了与以前相同的所有问题。

享受 :-)

task :orphaned_check => :environment do

  Dir[Rails.root.join('app/models/*.rb').to_s].each do |filename|
    klass = File.basename(filename, '.rb').camelize.constantize
    next unless klass.ancestors.include?(ActiveRecord::Base)

    orphanes = Hash.new

    klass.reflect_on_all_associations(:belongs_to).each do |belongs_to|
      assoc_name, field_name = belongs_to.name.to_s, belongs_to.foreign_key.to_s

      if belongs_to.options[:polymorphic]
        foreign_type_field = field_name.gsub('_id', '_type')
        foreign_types = klass.unscoped.select("DISTINCT(#{foreign_type_field})")
        foreign_types = foreign_types.collect { |r| r.send(foreign_type_field) }

        foreign_types.sort.each do |foreign_type|
          related_sql = foreign_type.constantize.unscoped.select(:id).to_sql

          finder = klass.unscoped.select(:id).where("#{foreign_type_field} = '#{foreign_type}'")
          finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphane|
            orphanes[orphane] ||= Array.new
            orphanes[orphane] << [assoc_name, field_name]
          end
        end
      else
        class_name = (belongs_to.options[:class_name] || assoc_name).classify
        related_sql = class_name.constantize.unscoped.select(:id).to_sql

        finder = klass.unscoped.select(:id)
        finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphane|
          orphanes[orphane] ||= Array.new
          orphanes[orphane] << [assoc_name, field_name]
        end
      end
    end

    orphanes.sort_by { |record, data| record.id }.each do |record, data|
      data.sort_by(&:first).each do |assoc_name, field_name|
        puts "#{record.class.name}##{record.id} #{field_name} is present, but #{assoc_name} doesn't exist"
      end
    end
  end

end

(for the latest version of the script below, see https://gist.github.com/KieranP/3849777)

The problem with Martin's script is that it uses ActiveRecord to first pull records, then find the associations, then fetch the associations. It generates a ton of SQL calls for each of the associations. It's not bad for a small app, but when you have a multiple tables with 100k records and each with 5+ belongs_to, it can take well into the 10+ minute mark to complete.

The following script uses SQL instead, looks for orphaned belongs_to associations for all models in app/models within a Rails app. It handles simple belongs_to, belongs_to using :class_name, and polymorphic belongs_to calls. On the production data I was using, it dropped the runtime of a slightly modified version of Martin's script from 9 minutes to just 8 seconds, and it found all the same issues as before.

Enjoy :-)

task :orphaned_check => :environment do

  Dir[Rails.root.join('app/models/*.rb').to_s].each do |filename|
    klass = File.basename(filename, '.rb').camelize.constantize
    next unless klass.ancestors.include?(ActiveRecord::Base)

    orphanes = Hash.new

    klass.reflect_on_all_associations(:belongs_to).each do |belongs_to|
      assoc_name, field_name = belongs_to.name.to_s, belongs_to.foreign_key.to_s

      if belongs_to.options[:polymorphic]
        foreign_type_field = field_name.gsub('_id', '_type')
        foreign_types = klass.unscoped.select("DISTINCT(#{foreign_type_field})")
        foreign_types = foreign_types.collect { |r| r.send(foreign_type_field) }

        foreign_types.sort.each do |foreign_type|
          related_sql = foreign_type.constantize.unscoped.select(:id).to_sql

          finder = klass.unscoped.select(:id).where("#{foreign_type_field} = '#{foreign_type}'")
          finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphane|
            orphanes[orphane] ||= Array.new
            orphanes[orphane] << [assoc_name, field_name]
          end
        end
      else
        class_name = (belongs_to.options[:class_name] || assoc_name).classify
        related_sql = class_name.constantize.unscoped.select(:id).to_sql

        finder = klass.unscoped.select(:id)
        finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphane|
          orphanes[orphane] ||= Array.new
          orphanes[orphane] << [assoc_name, field_name]
        end
      end
    end

    orphanes.sort_by { |record, data| record.id }.each do |record, data|
      data.sort_by(&:first).each do |assoc_name, field_name|
        puts "#{record.class.name}##{record.id} #{field_name} is present, but #{assoc_name} doesn't exist"
      end
    end
  end

end
韶华倾负 2024-10-17 03:43:51

这可能取决于您想对孤儿采取什么行动。也许您只是想删除它们?通过几个 SQL 查询就可以轻松解决这个问题。

This might depend on what action you want to take with the orphans. Perhaps you just want to delete them? That would be easily solved with a couple of SQL queries.

眸中客 2024-10-17 03:43:51

具有相同的任务,并且当前的发现者以以下方式结束:

Product.where.not(category_id: Category.pluck("id")).delete_all

摆脱所有同时失去其类别的产品。

Had the same task and with the current finders ended along the lines of:

Product.where.not(category_id: Category.pluck("id")).delete_all

to get rid of all Products, which have lost their Category meanwhile.

南冥有猫 2024-10-17 03:43:51

您可以创建 Rake 任务来搜索和处理孤立记录,例如:

namespace :db do
  desc "Handle orphans"
  task :handle_orphans => :environment do
    Dir[Rails.root + "app/models/**/*.rb"].each do |path|
      require path
    end
    ActiveRecord::Base.send(:descendants).each do |model|
      model.reflections.each do |association_name, reflection|
        if reflection.macro == :belongs_to
          model.all.each do |model_instance|
            unless model_instance.send(reflection.primary_key_name).blank?
              if model_instance.send(association_name).nil?
                print "#{model.name} with id #{model_instance.id} has an invalid reference, would you like to handle it? [y/n]: "
                case STDIN.gets.strip
                  when "y", "Y"
                    # handle it
                end
              end
            end
          end
        end
      end
    end
  end
end

You can create a Rake task to search for and handle orphaned records, for example:

namespace :db do
  desc "Handle orphans"
  task :handle_orphans => :environment do
    Dir[Rails.root + "app/models/**/*.rb"].each do |path|
      require path
    end
    ActiveRecord::Base.send(:descendants).each do |model|
      model.reflections.each do |association_name, reflection|
        if reflection.macro == :belongs_to
          model.all.each do |model_instance|
            unless model_instance.send(reflection.primary_key_name).blank?
              if model_instance.send(association_name).nil?
                print "#{model.name} with id #{model_instance.id} has an invalid reference, would you like to handle it? [y/n]: "
                case STDIN.gets.strip
                  when "y", "Y"
                    # handle it
                end
              end
            end
          end
        end
      end
    end
  end
end
熊抱啵儿 2024-10-17 03:43:51

假设您有一个用户可以订阅杂志的应用程序。对于 ActiveRecord 关联,它看起来像这样:

    # app/models/subscription.rb
    class Subscription < ActiveRecord::Base
      belongs_to :magazine
      belongs_to :user
    end

    # app/models/user.rb
    class User < ActiveRecord::Base
      has_many :subscriptions
      has_many :users, through: :subscriptions
    end

    # app/models/magazine.rb
    class Magazine < ActiveRecord::Base
      has_many :subscriptions
      has_many :users, through: :subscriptions
    end

不幸的是,有人忘记将 dependent: :destroy 添加到 has_many :subscriptions 中。当删除用户或杂志时,会留下孤立的订阅。

这个问题已被 dependent: :destroy 修复,但仍然存在大量孤立记录。
您可以使用两种方法来删除孤立记录。

方法 1 — 不良气味

Subscription.find_each do |subscription|
  if subscription.magazine.nil? || subscription.user.nil?
    subscription.destroy
  end
end

这对每条记录执行单独的 SQL 查询,检查它是否是孤立的,如果是则销毁它。

方法 2 — 好闻

Subscription.where([
  "user_id NOT IN (?) OR magazine_id NOT IN (?)",
  User.pluck("id"),
  Magazine.pluck("id")
]).destroy_all

这种方法首先获取所有用户和杂志的 ID,然后执行一个查询来查找不属于用户或查询的所有订阅。

Let’s say you have an application where a User can subscribe to a Magazine. With ActiveRecord associations, it would look something like this:

    # app/models/subscription.rb
    class Subscription < ActiveRecord::Base
      belongs_to :magazine
      belongs_to :user
    end

    # app/models/user.rb
    class User < ActiveRecord::Base
      has_many :subscriptions
      has_many :users, through: :subscriptions
    end

    # app/models/magazine.rb
    class Magazine < ActiveRecord::Base
      has_many :subscriptions
      has_many :users, through: :subscriptions
    end

Unfortunately, someone forgot to add dependent: :destroy to the has_many :subscriptions. When a user or magazine was deleted, an orphaned subscription was left behind.

This issue was fixed by dependent: :destroy, but there was still a large number of orphaned records lingering around.
There are two ways you can use to remove the orphaned records.

Approach 1 — Bad Smell

Subscription.find_each do |subscription|
  if subscription.magazine.nil? || subscription.user.nil?
    subscription.destroy
  end
end

This executes a separate SQL query for each record, checks whether it is orphaned, and destroys it if it is.

Approach 2 — Good Smell

Subscription.where([
  "user_id NOT IN (?) OR magazine_id NOT IN (?)",
  User.pluck("id"),
  Magazine.pluck("id")
]).destroy_all

This approach first gets the IDs of all Users and Magazines, and then executes one query to find all Subscriptions that don’t belong to either a User or a Query.

累赘 2024-10-17 03:43:51

KieranP 的回答对我有很大帮助,但他的脚本不处理命名空间类。我添加了几行来执行此操作,同时忽略关注目录。如果您想删除所有孤立记录,我还添加了一个可选的 DELETE=true 命令行参数。

namespace :db do
  desc "Find orphaned records. Set DELETE=true to delete any discovered orphans."
  task :find_orphans => :environment do

    found = false

    model_base = Rails.root.join('app/models')

    Dir[model_base.join('**/*.rb').to_s].each do |filename|

      # get namespaces based on dir name
      namespaces = (File.dirname(filename)[model_base.to_s.size+1..-1] || '').split('/').map{|d| d.camelize}.join('::')

      # skip concerns folder
      next if namespaces == "Concerns"

      # get class name based on filename and namespaces
      class_name = File.basename(filename, '.rb').camelize
      klass = "#{namespaces}::#{class_name}".constantize

      next unless klass.ancestors.include?(ActiveRecord::Base)

      orphans = Hash.new

      klass.reflect_on_all_associations(:belongs_to).each do |belongs_to|
        assoc_name, field_name = belongs_to.name.to_s, belongs_to.foreign_key.to_s

        if belongs_to.options[:polymorphic]
          foreign_type_field = field_name.gsub('_id', '_type')
          foreign_types = klass.unscoped.select("DISTINCT(#{foreign_type_field})")
          foreign_types = foreign_types.collect { |r| r.send(foreign_type_field) }

          foreign_types.sort.each do |foreign_type|
            related_sql = foreign_type.constantize.unscoped.select(:id).to_sql

            finder = klass.unscoped.where("#{foreign_type_field} = '#{foreign_type}'")
            finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphan|
              orphans[orphan] ||= Array.new
              orphans[orphan] << [assoc_name, field_name]
            end
          end
        else
          class_name = (belongs_to.options[:class_name] || assoc_name).classify
          related_sql = class_name.constantize.unscoped.select(:id).to_sql

          finder = klass.unscoped
          finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphan|
            orphans[orphan] ||= Array.new
            orphans[orphan] << [assoc_name, field_name]
          end
        end
      end

      orphans.sort_by { |record, data| record.id }.each do |record, data|
        found = true
        data.sort_by(&:first).each do |assoc_name, field_name|
          puts "#{record.class.name}##{record.id} #{field_name} is present, but #{assoc_name} doesn't exist" + (ENV['DELETE'] ? ' -- deleting' : '')
          record.delete if ENV['DELETE']
        end
      end
    end

    puts "No orphans found" unless found
  end
end

KieranP's answer was a big help for me but his script does not handle namespaced classes. I added a few lines to do so, whilst ignoring the concerns directory. I also added an optional DELETE=true command line arg if you want to nuke all orphaned records.

namespace :db do
  desc "Find orphaned records. Set DELETE=true to delete any discovered orphans."
  task :find_orphans => :environment do

    found = false

    model_base = Rails.root.join('app/models')

    Dir[model_base.join('**/*.rb').to_s].each do |filename|

      # get namespaces based on dir name
      namespaces = (File.dirname(filename)[model_base.to_s.size+1..-1] || '').split('/').map{|d| d.camelize}.join('::')

      # skip concerns folder
      next if namespaces == "Concerns"

      # get class name based on filename and namespaces
      class_name = File.basename(filename, '.rb').camelize
      klass = "#{namespaces}::#{class_name}".constantize

      next unless klass.ancestors.include?(ActiveRecord::Base)

      orphans = Hash.new

      klass.reflect_on_all_associations(:belongs_to).each do |belongs_to|
        assoc_name, field_name = belongs_to.name.to_s, belongs_to.foreign_key.to_s

        if belongs_to.options[:polymorphic]
          foreign_type_field = field_name.gsub('_id', '_type')
          foreign_types = klass.unscoped.select("DISTINCT(#{foreign_type_field})")
          foreign_types = foreign_types.collect { |r| r.send(foreign_type_field) }

          foreign_types.sort.each do |foreign_type|
            related_sql = foreign_type.constantize.unscoped.select(:id).to_sql

            finder = klass.unscoped.where("#{foreign_type_field} = '#{foreign_type}'")
            finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphan|
              orphans[orphan] ||= Array.new
              orphans[orphan] << [assoc_name, field_name]
            end
          end
        else
          class_name = (belongs_to.options[:class_name] || assoc_name).classify
          related_sql = class_name.constantize.unscoped.select(:id).to_sql

          finder = klass.unscoped
          finder.where("#{field_name} NOT IN (#{related_sql})").each do |orphan|
            orphans[orphan] ||= Array.new
            orphans[orphan] << [assoc_name, field_name]
          end
        end
      end

      orphans.sort_by { |record, data| record.id }.each do |record, data|
        found = true
        data.sort_by(&:first).each do |assoc_name, field_name|
          puts "#{record.class.name}##{record.id} #{field_name} is present, but #{assoc_name} doesn't exist" + (ENV['DELETE'] ? ' -- deleting' : '')
          record.delete if ENV['DELETE']
        end
      end
    end

    puts "No orphans found" unless found
  end
end
爱的故事 2024-10-17 03:43:51

我创建了一个名为 OrphanRecords 的 gem。它提供了用于显示/删除孤立记录的 rake 任务。目前不支持 HABTM 关联,​​如果您有兴趣,请随时贡献 :)

I have created a gem called OrphanRecords. It provides rake tasks for show/delete the orphan records. Currently it is not supporting HABTM association, if you are interested please feel free to contribute :)

合久必婚 2024-10-17 03:43:51

我在我的 gem 中编写了一个方法来执行此操作 PolyBelongsTo

您可以通过调用来查找所有孤立记录任何 ActiveRecord 模型上的 pbt_orphans 方法。

Gemfile

gem 'poly_belongs_to'

代码示例

User.pbt_orphans
# => #<ActiveRecord::Relation []> # nil for objects without belongs_to
Story.pbt_orphans
# => #<ActiveRecord::Relation []> # nil for objects without belongs_to

返回所有孤立记录。

如果您只想检查单个记录是否为孤立记录,可以使用 :orphan? 方法来完成。

User.first.orphan?
Story.find(5).orphan?

适用于多态关系和非多态关系。

作为奖励,如果您想查找具有无效类型的多态记录,您可以执行以下操作:

Story.pbt_mistyped

返回 Story 记录中使用的无效 ActiveRecord 模型名称的记录数组。记录的类型如 [“Object”、“Class”、“Storyable”]。

I've written a method to do just this in my gem PolyBelongsTo

You can find all orphaned records by calling the pbt_orphans method on any ActiveRecord model.

Gemfile

gem 'poly_belongs_to'

Code example

User.pbt_orphans
# => #<ActiveRecord::Relation []> # nil for objects without belongs_to
Story.pbt_orphans
# => #<ActiveRecord::Relation []> # nil for objects without belongs_to

All orphaned records are returned.

If you just want to check if a single record is orphaned you can do it with the :orphan? method.

User.first.orphan?
Story.find(5).orphan?

Works for both polymorphic relations and non-polymorphic relations.

As a bonus if you want to find polymorphic records with invalid types you can do the following:

Story.pbt_mistyped

Returns an Array of records of invalid ActiveRecord model names used in your Story records. Records with types like ["Object", "Class", "Storyable"].

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文