module ForeignKeyChecker module Utils module BelongsTo CONST_REGEXP = /\A[A-Z]\p{Alnum}*(::\p{Alnum}+)*\z/ class Result attr_reader :primary_table, :dependant_table, :foreign_key, :foreign_type, :types, :polymorphic, :inverse_of, :name, :connection attr_writer :build_class_name def initialize(**args) args.each do |key, value| instance_variable_set("@#{key}", value) end end def association_code if inverse_of has_many_code else belongs_to_code end end def inversed_association(table) @inversed_associations ||= {} @inversed_associations[table] = build_inversed_association(table) @inversed_associations[table].build_class_name = @build_class_name if @build_class_name @inversed_associations[table] end def class_name build_class_name end def model return class_name.constantize if class_name.is_a?(String) class_name end def primary_model kls = build_primary_class_name return kls.constantize if kls.is_a?(String) kls end def dependant_model kls = build_dependant_class_name return kls.constantize if kls.is_a?(String) kls rescue NameError => e puts "#{e.class.to_s} #{e.message} #{e.backtrace.first}" nil end def delete_sql(ids) dependent_sql(ids, prefix: 'DELETE FROM') end def select_sql_with_fk(ids) dependent_sql(ids, prefix: "SELECT id, #{connection.quote_column_name(foreign_key)} FROM") end def select_sql(ids) dependent_sql(ids) end def nullify_sql(ids) dependent_sql(ids, prefix: 'UPDATE', suffix: nullify_suffix) end def has_many? !belongs_to? end def belongs_to? !inverse_of end def inspect "#{class_name}.#{association_code}" end def conflict_with_fk?(fk) return false if polymorphic fk.from_column == foreign_key && fk.from_table == dependant_table && (fk.to_table != primary_table || fk.to_column != 'id') end private def dependent_sql(ids, prefix: 'SELECT id FROM', suffix: '') return nil if belongs_to? return nil if ids.empty? return if class_name.is_a?(ActiveRecord::Base) && class_name.primary_key.nil? conn = connection if polymorphic [ "#{prefix} #{conn.quote_table_name(dependant_table.to_s)} #{suffix} WHERE #{conn.quote_column_name(foreign_key)} IN (#{ids.map(&:to_i).join(',')}) AND #{conn.quote_column_name(foreign_type)} = $1", nil, [class_name.to_s], ] else ["#{prefix} #{conn.quote_table_name(dependant_table.to_s)} #{suffix} WHERE #{conn.quote_column_name(foreign_key)} IN (#{ids.map(&:to_i).join(',')})"] end end def nullify_suffix conn = connection if polymorphic "SET #{conn.quote_column_name(foreign_key)} = NULL, #{conn.quote_column_name(foreign_type)} = NULL" else "SET #{conn.quote_column_name(foreign_key)} = NULL" end end def build_inversed_association(table) table = table.table_name if table.is_a?(ActiveRecord::Base) if polymorphic self.class.new(primary_table: table, dependant_table: dependant_table, foreign_key: foreign_key, foreign_type: foreign_type, polymorphic: true, inverse_of: self, connection: connection) else self.class.new(primary_table: primary_table, connection: connection, dependant_table: dependant_table, foreign_key: foreign_key, inverse_of: self) end end def has_many_code if polymorphic has_many_polymorphic_code else has_many_simple_code end end def belongs_to_code if polymorphic "belongs_to :#{belongs_to_relation_name}, polymorphic: true, foreign_key: :#{foreign_key}, foreign_type: :#{foreign_type}" else "belongs_to :#{belongs_to_relation_name}, class_name: '#{build_primary_class_name}', foreign_key: :#{foreign_key}" end end def has_many_polymorphic_code "has_many :#{has_many_relation_name}, class_name: '#{build_dependant_class_name}', polymorphic: true, foreign_key: :#{foreign_key}, foreign_type: :#{foreign_type}, dependent: :destroy" end def has_many_simple_code "has_many :#{has_many_relation_name}, class_name: '#{build_dependant_class_name}', foreign_key: :#{foreign_key}, dependent: :destroy" end def has_many_relation_name dependant_table.tr('.', '_').underscore.pluralize end def belongs_to_relation_name foreign_key.delete_suffix('_id') #return polymorphic_belongs_to_relation_name if polymorphic #primary_table.tr('.', '_').underscore.singularize end def polymorphic_belongs_to_relation_name @name end def build_class_name(arg = _table) @build_class_name.call(arg) end def build_primary_class_name build_class_name(primary_table) end def build_dependant_class_name build_class_name(dependant_table) end def type_to_table(type) @type_to_table.is_a?(Proc) ? @type_to_table.call(type) : type.underscore.pluralize end def _table return dependant_table if belongs_to? primary_table end end class TableLevel attr_reader :connection, :table, :columns, :columns_of_table, :polymorphic_suffixes, :fks def initialize(connection, table, columns_of_table, polymorphic_suffixes, fks:, on_error: proc { } ) @connection = connection @table = table @columns = columns_of_table[table] @columns_of_table = columns_of_table @polymorphic_suffixes = polymorphic_suffixes @on_error = on_error @fks = fks end def foreign_key_suffix '_id' end def candidates columns.select { |column| column.name.ends_with?(foreign_key_suffix) }.each_with_object([]) do |column, object| level = ColumnLevel.new(self, column) level.errors.each(&@on_error) [level.association].compact.each do |ass| next if fks.find { |fk| ass.conflict_with_fk?(fk) } object.push(ass) end end end end class ColumnLevel attr_reader :table_level, :column def initialize(table_level, column) @table_level = table_level @column = column @errors = [] @association_name = column.name.delete_suffix(table_level.foreign_key_suffix) @polymorphic_column_names = polymorphic_column_names end def association perform if !@done @association end def errors perform if !@done @errors end def polymorphic? perform if !@done @polymorphic end private def perform check_polymorphic! check_common! @done = true end def check_polymorphic! if @polymorphic_column_names.size > 1 @errors.push("Can't determine polymorphic column for association #{@association_name}; candidates: #{@polymorphic_columns.join(', ')}") elsif @polymorphic_column_names.size == 1 conn = table_level.connection foreign_type = @polymorphic_column_names.first types = conn.select_all("SELECT DISTINCT #{conn.quote_column_name(foreign_type)} FROM #{conn.quote_table_name(table_level.table)}").rows.map(&:first).compact.select { |tp| tp.match(CONST_REGEXP) } @association = Result.new(polymorphic: true, foreign_key: column.name, foreign_type: foreign_type, types: types, name: @association_name, dependant_table: table_level.table, connection: conn) @polymorphic = true end end def check_common! if @polymorphic_column_names.size != 0 return end matched_table = table_level.columns_of_table.keys.find do |table1| next false if table1.singularize != @association_name next false if table_level.columns_of_table[table1].find { |col| col.name == 'id' }&.type != column.type true end if matched_table @association = Result.new(polymorphic: false, foreign_key: column.name, primary_table: matched_table, dependant_table: table_level.table, connection: table_level.connection) @polymorphic = false end end def polymorphic_column_names table_level.polymorphic_suffixes.map do |polymorphic_suffix| column_candidate_name = "#{@association_name}#{polymorphic_suffix}" next unless table_level.columns.find { |col| col.name == column_candidate_name && col.type == :string } column_candidate_name end.compact end end def self.build_table_mapping(model: ActiveRecord::Base, module_if_gt: 3) Rails.application.eager_load! base_class = model loop do if !base_class.superclass.respond_to?(:connection) || base_class.superclass.connection != base_class.connection break end base_class = base_class.superclass end @table_mappings ||= {} @table_mappings[base_class] ||= begin start_hash = base_class.descendants.reject do |_model| _model.abstract_class || (_model.connection != model.connection) end.group_by(&:table_name).transform_values do |models| next models.first if models.size == 1 root_models = models.map do |_model| if _model.to_s.demodulize.starts_with?("HABTM_") next end loop do break if !_model.superclass.respond_to?(:abstract_class?) || _model.superclass.table_name.nil? _model = _model.superclass end _model end.compact.uniq if root_models.size > 1 raise "More than one root model for table #{models.first.table_name}: #{root_models.inspect}" end root_models.first end all_tables = ForeignKeyChecker::Utils.get_tables(model) #group_tables(all_tables) all_tables.each_with_object(start_hash) do |table, object| object[table] ||= table.tr('.', '_').singularize.camelize end end end def self.group_tables(tables, level: 1) if tables.size < 2 return tables end tables.group_by do |table| table.split(/[\._]/).first(level).join('_') end.transform_values do |children| group_tables(children, level: level + 1) end end def self.column_name_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {}) columns_of_table = ForeignKeyChecker::Utils.get_columns(connection) columns_of_table.each_with_object({}) do |(table, columns), object| object[table] = TableLevel.new(connection, table, columns_of_table, polymorphic_suffixes, on_error: on_error, fks: fks).candidates end end def self.all_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {}) merge_candidates( fk_candidates(connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error), column_name_candidates(connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error) ) end def self.all_association_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {}) belongs_to = all_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {}) has_many = inverse(belongs_to) belongs_to.each_with_object({}) do |(table, bt_associations), object| object[table] = {belongs_to: bt_associations, has_many: has_many[table] || []} end end def self.inverse(hash) hash.each_with_object({}) do |(table, bt_associations), object| bt_associations.each do |association| object[association.primary_table] ||= [] object[association.primary_table].push(association.inversed_association(table)) end end.transform_values do |associations| associations.uniq do |result| "#{result.polymorphic} #{result.foreign_key} #{result.primary_table} #{result.dependant_table} #{result.name} #{result.types&.join(',')}" end end end def self.fks @fks ||= ForeignKeyChecker::Utils.get_foreign_keys end def self.fk_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {}) fks.to_a.each_with_object({}) do |datum, obj| obj[datum.from_table] ||= [] obj[datum.from_table].push(Result.new(polymorphic: false, foreign_key: datum.from_column, dependant_table: datum.from_table, primary_table: datum.to_table, connection: connection)) end end def self.merge_candidates(*hashes) hashes.each_with_object({}) do |hash, object| hash.each do |table, candidates| object[table] ||= [] object[table] = (object[table] + candidates).uniq do |result| "#{result.polymorphic} #{result.foreign_key} #{result.primary_table} #{result.dependant_table} #{result.name} #{result.types&.join(',')}" end end end end # ForeignKeyChecker::Utils::BelongsTo.build_classes(User) def self.build_classes(model, polymorphic_suffixes: ['_type'], on_error: proc {}) @build_classes ||= begin mapping = build_table_mapping(model: model) build_class_name = proc { |table| mapping[table] } all_association_candidates(model.connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error).map do |table, hash| hash.transform_values { |results| results.map { |result| result.build_class_name = build_class_name; result }}.merge( table_name: table, class_name: mapping[table], ) end end end # ForeignKeyChecker::Utils::BelongsTo.build_delete_chain(City.where(slug: 'paris'), {countries: :capital_city_id, cities: %i[curator_id main_city_id]}) # ForeignKeyChecker::Utils::BelongsTo.build_delete_chain(City.where(slug: 'paris'), {countries: :capital_city_id, cities: %i[curator_id main_city_id], local_profiles: :published_edition_id, hint_places: :place_image_id}, {}) def self.build_delete_chain(scope, nullify = {}, **args) tree = build_hm_tree(scope.model, **args) all_ids = [] dependencies = {} triggers = {} cmds = [proc { |*args| __ids = scope.pluck(:id); all_ids << [scope.model.table_name, __ids, []]; __ids}] process_tree = proc do |_tree, way| _tree.each do |table, data| cmds << proc do |*args| primary_table = data[:associations].first.primary_table ids = all_ids.select { |tn, __ids| tn == primary_table }.map(&:second).reduce(&:+).uniq _id_fks = [] data[:associations].each do |association| association.select_sql_with_fk(ids.dup).try do |sql| begin if Array.wrap(nullify[association.dependant_table.to_sym]).include?(association.foreign_key.to_sym) ids.in_groups_of(1000, false).each do |group| all_ids << [";sql;", association.nullify_sql(group), way] end next end p way _id_fks += association.connection.select_all(*sql).rows rescue => e raise e unless e.message.tr('"', '').include?("column id does not exist") ids.in_groups_of(1000, false).each do |group| all_ids << [";sql;", association.delete_sql(group), way] end puts e.message end end end _id_fks.each do |_id, _fk| dependencies[primary_table] ||= {} dependencies[primary_table][_fk] ||= Set.new dependencies[primary_table][_fk] << [table, _id] triggers[table] ||= {} triggers[table][_id] ||= Set.new triggers[table][_id] << [primary_table, _fk] end _ids = _id_fks.map(&:first).uniq - (all_ids.select { |tn, __ids| tn == table }.map(&:second).reduce([], &:+).uniq) if _ids.any? all_ids << [table, _ids, way] data[:children].presence.try { |ch| process_tree.call(ch, way + [table]) } end _ids end end end process_tree.call(tree, [scope.model.table_name]) arg = nil cmds.each do |cmd| arg = cmd.call(arg) end sql_queries = all_ids.select { |a, b, c| a == ';sql;' }.map(&:second) t_ids = all_ids.reject { |a, b, c| a == ';sql;' }.group_by(&:first).transform_values { |vs| vs.map(&:second).reduce([], &:+).uniq }.to_a loop do break unless t_ids.map(&:second).any?(&:any?) any = false t_ids.each do |table, ids| _ids = ids.select { |id| dependencies.dig(table, id).blank? } ids.reject! { |id| dependencies.dig(table, id).blank? } if _ids.present? any = true _ids.in_groups_of(1000, false).each do |ids_group| sql_queries << ["DELETE FROM #{scope.model.connection.quote_table_name(table)} WHERE id IN (#{ids_group.map(&:to_i).join(',')})"] end _ids.each do |id| triggers.dig(table, id)&.each do |keys| dependencies.dig(*keys).delete([table, id]) end end end end unless any puts "Cannot destroy these objects. Check cyclic relation chains:\n#{find_cycles(tree, [scope.model.table_name]).inspect}" return [] end end sql_queries end class WayPoint attr_reader :table def initialize(foreign_key, table) @foreign_key = foreign_key @table = table end def inspect return @table if @foreign_key.blank? "->#{@foreign_key}(#{@table})" end def ==(value) return @table == value if value.is_a?(String) value.table == @table end end def self.find_ways(node, way) way = way.map { |wp| next WayPoint.new(nil, wp) if wp.is_a?(String); wp} return [way] if node.nil? node.each_with_object([]) do |(key, item), obj| tails = if way.include?(key) [way + [key]] else find_ways(item[:children], way + [key]) end item[:associations].each do |ass| tails.each do |tail| obj.push((tail[0...way.size]) + [WayPoint.new(ass.foreign_key, ass.dependant_table)] + (tail[(way.size + 1)..-1] || [])) end end end end def self.find_cycles(tree, start) find_ways(tree, start).each_with_object([]) do |way, obj| way[0..-2].index(way.last).try do |idx| obj.push ([WayPoint.new(nil, way[idx].table)] + way[idx+1..-1]) end end.uniq(&:inspect) end # ForeignKeyChecker::Utils::BelongsTo.ways_for(User) def self.ways_for(model) find_ways(build_hm_tree(model), [model.table_name]) end # ForeignKeyChecker::Utils::BelongsTo.ways_for(User) def self.cycles_for(model) find_cycles(build_hm_tree(model), [model.table_name]) end # ForeignKeyChecker::Utils::BelongsTo.build_hm_tree(User) # построит дерево зависимостей от модели User #find_cycle = proc { |node, way| puts way.join('->'); next [way] if node.nil?; node.each_with_object([]) { |(key, item), obj| ways = way.include?(key) ? [way + [key]] : find_cycle.call(item[:children], way + [key]); ways.each { |w| obj.push(w) } } v} def self.build_hm_tree(model, **args) mapping = build_table_mapping(model: model) hash = build_classes(model, **args).group_by do |result| result[:table_name] end found = {} processing = {} should_fill = [] find_children = proc do |c_table_name| processing[c_table_name] = true hash[c_table_name].each_with_object({}) do |results, object| perform_result = proc do |result| key = result.dependant_table object[key] ||= {associations: []} object[key][:associations].push(result) if processing[key] should_fill.push([key, object[key]]) next end found[key] ||= find_children.call(key) object[key][:children] ||= found[key] end results[:has_many].each(&perform_result) hash.each_value do |hs| hs.each do |h| h[:belongs_to].each do |r| next unless r.polymorphic && r.types.include?(mapping[c_table_name]) perform_result.call(r.inversed_association(c_table_name)) end end end end end ret = find_children.call(model.table_name) should_fill.each do |table_name, item| item[:children] = found[table_name] end ret end end end end