require "foreign_key_checker/railtie" require 'foreign_key_checker/utils' module ForeignKeyChecker::Checkers end require 'foreign_key_checker/checkers/relations' require 'foreign_key_checker/checkers/tables' require 'foreign_key_checker/utils/belongs_to' module ForeignKeyChecker class TypeMismatch < StandardError; end class Result attr_reader :model, :association def initialize(data) data.each do |key, value| instance_variable_set("@#{key}", value) end end def from_table model.table_name end def to_table association.klass.table_name end def from_column association.foreign_key end def to_column association.klass.primary_key end def human_relation "#{from_table} belongs_to #{to_table} (by column #{from_column} to #{to_column})" end def message; end def inspect "#<#{self.class.name}:#{self.object_id} #{message}>" end def nullable? model.columns_hash[from_column.to_s].null end end class ForeignKeyResult < Result attr_reader :scope def message "There is no foreign_key for relation #{human_relation}\n" end def migration "add_foreign_key :#{from_table}, :#{to_table}#{ ", column: :#{from_column}" if from_column.to_s != "#{to_table.singularize}_id" }#{ ", primary_key: :#{to_column}" if to_column.to_s != 'id' }" end def to_zombie ZombieResult.new(scope: scope, model: model, association: association) end end class IndexResult < Result def message "There is no index for relation #{human_relation}\n" end end class ZombieResult < Result attr_reader :zombies, :scope def sql scope.to_sql end def delete_sql from_t = model.connection.quote_table_name(from_table) from_c = model.connection.quote_column_name(from_column) to_t = model.connection.quote_table_name(to_table) to_c = model.connection.quote_column_name(to_column) #"DELETE #{from_t} FROM #{from_t}.#{from_c} LEFT OUTER JOIN #{to_t} ON #{to_t}.#{to_c} = #{from_t}.#{from_c} WHERE #{to_t}.#{to_c} IS NULL" "DELETE FROM #{from_t} WHERE #{from_c} IS NOT NULL AND #{from_c} NOT IN (SELECT * FROM (SELECT #{to_c} FROM #{to_t}) AS t )" end def set_null_sql from_t = model.connection.quote_table_name(from_table) from_c = model.connection.quote_column_name(from_column) to_t = model.connection.quote_table_name(to_table) to_c = model.connection.quote_column_name(to_column) #"UPDATE #{from_t} SET #{from_t}.#{from_c} = NULL FROM #{from_t} LEFT OUTER JOIN #{to_t} ON #{to_t}.#{to_c} = #{from_t}.#{from_c} WHERE #{to_t}.#{to_c} IS NULL" "UPDATE #{from_t} SET #{from_c} = NULL WHERE #{from_c} IS NOT NULL AND #{from_c} NOT IN (SELECT * FROM (SELECT #{to_c} FROM #{to_t}) AS t )" end def set_null_migration "execute('#{set_null_sql}')" end def migration(set_null: false) return set_null_migration if set_null "execute('#{delete_sql}')" end def message "#{human_relation} with #{zombies} zombies; processed by statement:\n#{sql}\n" end end class BrokenRelationResult < Result attr_reader :error def message "#{human_relation} is bloken with error #{error.class.name}: #{error.message}" end end class Checker DEFAULT_OPTIONS = { excluded_modules: [], specification_names: ['primary'], foreign_keys: true, indexes: true, zombies: true, polymorphic_zombies: true, } DEFAULT_OPTIONS.keys.each { |key| attr_reader key } def initialize(options = {}) @options = DEFAULT_OPTIONS.merge(options) @options.each do |key, value| if DEFAULT_OPTIONS.has_key?(key) instance_variable_set("@#{key}", value) end end @result = { zombies: [], foreign_keys: [], indexes: [], broken: [], } end def specification_names=(value) value.map(&:to_s) end def excluded_model?(model) excluded_modules.each do |mod_name| return true if model.to_s.starts_with?(mod_name) end false end def excluded_specification?(model) !specification_names.include?(model.connection_specification_name.to_s) end def check_polymorphic_bt_association(model, association) end def check_types(model, association) type_from = model.columns_hash[association.foreign_key.to_s].sql_type type_to = association.klass.columns_hash[association.klass.primary_key.to_s].sql_type raise TypeMismatch, "TypeMissMatch for relation #{model}##{association.name} #{type_from} != #{type_to}" if type_from != type_to end def check_foreign_key_bt_association(model, association) return if model.name.starts_with?('HABTM_') begin related = association.klass rescue NameError => error @result[:broken] << BrokenRelationResult.new( model: model, association: association, error: error, ) return end column_name = model.connection.quote_column_name(association.foreign_key) scope = model.left_outer_joins(association.name).where( "#{related.quoted_table_name}.#{related.quoted_primary_key} IS NULL AND #{model.quoted_table_name}.#{column_name} IS NOT NULL" ) check_types(model, association) if zombies number = scope.count if number > 0 @result[:zombies] << ZombieResult.new( model: model, association: association, scope: scope, zombies: number, ) end end if foreign_keys && !model.connection.foreign_key_exists?(model.table_name, related.table_name, column: association.foreign_key, primary_key: related.primary_key) scope.first @result[:foreign_keys] << ForeignKeyResult.new( model: model, association: association, scope: scope, ) end if indexes && !model.connection.index_exists?(model.table_name, association.foreign_key) @result[:indexes] << IndexResult.new( model: model, association: association, ) end rescue ActiveRecord::InverseOfAssociationNotFoundError, ActiveRecord::StatementInvalid, TypeMismatch => error @result[:broken] << BrokenRelationResult.new( model: model, association: association, error: error, ) end def already_done_fk?(model, association) @_done ||= {} ret = @_done[[model.table_name, association.foreign_key]] @_done[[model.table_name, association.foreign_key]] = true ret end def check Rails.application.eager_load! ActiveRecord::Base.descendants.each do |model| next if excluded_model?(model) next if excluded_specification?(model) model.reflect_on_all_associations(:belongs_to).each do |association| if association.options[:polymorphic] && polymorphic_zombies check_polymorphic_bt_association(model, association) next end next if already_done_fk?(model, association) check_foreign_key_bt_association(model, association) end end @result end end class << self def check(options = {}) Checker.new(options).check end def hm_tree(model, **args) ForeignKeyChecker::Utils::BelongsTo.build_hm_tree(model, **args) end end end