Ищете что-то, что может проходить через отношения, определенные в моделях, и может проверять БД на наличие потерянных записей / неработающих ссылок между таблицами.
Какие-нибудь плагины / драгоценные камни rails для обнаружения потерянных записей?
Ответы (8)
Это может зависеть от того, какие действия вы хотите предпринять с сиротами. Возможно, вы просто хотите их удалить? Это было бы легко решить с помощью пары SQL-запросов.
(последнюю версию приведенного ниже скрипта см. на странице https://gist.github.com/KieranP/3849777 < / а>)
Проблема со сценарием Мартина заключается в том, что он использует ActiveRecord, чтобы сначала извлекать записи, затем находить ассоциации, а затем извлекать ассоциации. Он генерирует множество запросов SQL для каждой ассоциации. Это неплохо для небольшого приложения, но когда у вас есть несколько таблиц с 100 тыс. Записей, каждая из которых имеет 5+ own_to, для завершения может потребоваться 10+ минут.
В следующем сценарии вместо этого используется SQL, ищущий потерянные ассоциации own_to для всех моделей в приложении / моделях в приложении Rails. Он обрабатывает простые вызовы own_to, own_to с использованием: class_name и полиморфные вызовы own_to. На производственных данных, которые я использовал, время выполнения слегка измененной версии сценария Мартина сократилось с 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
Если бы та же задача и с текущими искателями закончилась по строкам:
Product.where.not(category_id: Category.pluck("id")).delete_all
чтобы избавиться от всех товаров, которые тем временем потеряли свою категорию.
Вы можете создать задачу 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
Допустим, у вас есть приложение, в котором пользователь может подписаться на журнал. С ассоциациями 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
К сожалению, кто-то забыл добавить зависимые:: destroy в has_many: subscriptions. Когда пользователь или журнал удалялся, оставалась осиротевшая подписка.
Эта проблема была исправлена зависимым:: 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
Этот подход сначала получает идентификаторы всех пользователей и журналов, а затем выполняет один запрос, чтобы найти все подписки, которые не принадлежат ни пользователю, ни запросу.
Ответ 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
Я создал гем под названием OrphanRecords. Он предоставляет рейк-задачи для отображения / удаления потерянных записей. В настоящее время он не поддерживает ассоциацию HABTM, если вы заинтересованы, не стесняйтесь внести свой вклад :)
Я написал способ сделать это в моем геме PolyBelongsTo
Вы можете найти все потерянные записи, вызвав метод pbt_orphans в любой модели ActiveRecord.
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
Возвращает массив записей недопустимых имен моделей ActiveRecord, используемых в ваших записях истории. Записи с такими типами, как ["Object", "Class", "Storyable"].
missing
. github.com/rails/rails/pull/34727 - person slothbear   schedule 08.08.2020