diff --git a/lib/tasks/data/generate_foreign_key_migrations.rake b/lib/tasks/data/generate_foreign_key_migrations.rake deleted file mode 100755 index 5818fe34a9..0000000000 --- a/lib/tasks/data/generate_foreign_key_migrations.rake +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -namespace :ofn do - namespace :data do - desc 'Generate Migrations for Missing Foreign Keys' - task generate_foreign_key_migrations: :environment do - Rails.application.eager_load! - model_classes = filter_model_classes - - orphaned_records = generate_migrations_and_counts(model_classes) - orphaned_records_queries, orphaned_records_counts = orphaned_records - - write_queries_to_file(orphaned_records_queries) - - summarize_counts(orphaned_records_queries, orphaned_records_counts) - end - - def filter_model_classes - Dir.glob(Rails.root.join('app/models/**/*.rb').to_s) - .map { |file| File.basename(file, '.rb').camelize } - .reject { |model| ["Gateway", "PayPalExpress", "Bogus", "BogusSimple"].include?(model) } - end - - def generate_migrations_and_counts(model_classes) - orphaned_records_queries = [] - orphaned_records_counts = [] - previous_models = {} - - ActiveRecord::Base.descendants.each do |model_class| - next unless model_classes.include?(model_class.name.demodulize) - - model_class.reflect_on_all_associations(:belongs_to).each do |association| - orphans = process_association(model_class, association, previous_models) - if orphans - orphaned_records_queries << orphans[0] - orphaned_records_counts << orphans[1] - end - end - end - - [orphaned_records_queries, orphaned_records_counts] - end - - def process_association(model_class, association, previous_models) - return if association.options[:polymorphic] - - foreign_key_table_name = determine_foreign_key_table_name(model_class, association) - foreign_key_column = "#{association.options[:foreign_key] || association.name}_id" - - # Filter out duplicate migrations - return if duplicate_migration?(model_class, foreign_key_table_name, previous_models) - - previous_models[model_class.table_name] ||= [] - previous_models[model_class.table_name] << foreign_key_table_name - - generate_migration(model_class, association, foreign_key_table_name, foreign_key_column) - - query = generate_orphaned_records_query(model_class, foreign_key_table_name, - foreign_key_column) - - count = execute_count_query(query) - - [query, count] - end - - def determine_foreign_key_table_name(model_class, association) - if association.options[:class_name] - class_name = association.options[:class_name].underscore.parameterize - foreign_key_table_name = class_name.tableize - else - foreign_key_table_name = association.class_name.underscore.parameterize.tableize - namespace = model_class.name.deconstantize - - unless association.class_name.deconstantize == namespace || namespace == "" || - ActiveRecord::Base.connection.table_exists?(foreign_key_table_name) - foreign_key_table_name = "#{namespace.underscore}_#{foreign_key_table_name}" - end - end - - foreign_key_table_name - end - - def generate_migration(model_class, association, foreign_key_table_name, foreign_key_column) - migration_name = "add_foreign_key_to_#{model_class.table_name}_#{foreign_key_table_name}" - migration_class_name = migration_name.camelize - migration_file_name = "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_" \ - "#{migration_name}.rb" - - File.open(migration_file_name, 'w') do |file| - file.puts <<~MIGRATION - class #{migration_class_name} < ActiveRecord::Migration[6.0] - def change - add_foreign_key :#{model_class.table_name}, :#{foreign_key_table_name}, column: :#{foreign_key_column} - end - end - MIGRATION - end - - puts "Migration generated: #{migration_file_name}" - end - - def generate_orphaned_records_query(model_class, foreign_key_table_name, foreign_key_column) - <<~SQL.squish - SELECT COUNT(*) FROM #{model_class.table_name} - LEFT JOIN #{foreign_key_table_name} ON #{model_class.table_name}.#{foreign_key_column} = #{foreign_key_table_name}.id - WHERE #{foreign_key_table_name}.id IS NULL - SQL - end - - def execute_count_query(query) - result = ActiveRecord::Base.connection.select_all(query) - result[0]["count"].to_i if result.present? - end - - def write_queries_to_file(queries) - File.open("lib/tasks/data/orphaned_records_queries.sql", 'w') do |file| - file.puts queries.join("\n\n") - end - end - - def summarize_counts(queries, counts) - puts "\nSummary:" - if counts.any?(&:positive?) - puts "Some orphaned records were found in the following queries:" - queries.each_with_index do |query, index| - if counts[index] > 0 - puts "Query #{index + 1}: #{query}" - puts "Count: #{counts[index]}" - end - end - else - puts "No orphaned records were found. Migrations are safe to proceed." - end - end - - def duplicate_migration?(model_class, foreign_key_table_name, previous_models) - model_class.connection.foreign_key_exists?(model_class.table_name, foreign_key_table_name) || - previous_models[model_class.table_name]&.include?(foreign_key_table_name) - end - end -end diff --git a/spec/models/database_spec.rb b/spec/models/database_spec.rb new file mode 100644 index 0000000000..5fe4406626 --- /dev/null +++ b/spec/models/database_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Database" do + excluded_models = ["Gateway", "PayPalExpress", "Bogus", "BogusSimple"].freeze + + it "should have foreign keys for models with a belongs_to relationship" do + pending "Consider adding foreign keys" + Rails.application.eager_load! + model_classes = filter_model_classes + + migrations = generate_migrations(model_classes) + + expect(migrations.length).to eq(0) + end + + def filter_model_classes + Dir.glob(Rails.root.join('app/models/**/*.rb').to_s) + .map { |file| File.basename(file, '.rb').camelize } + .reject { |model| excluded_models.include?(model) } + end + + def generate_migrations(model_classes) + migrations = [] + previous_models = {} + + ActiveRecord::Base.descendants.each do |model_class| + next unless model_classes.include?(model_class.name.demodulize) + + model_class.reflect_on_all_associations(:belongs_to).each do |association| + migration = process_association(model_class, association, previous_models) + migrations << migration unless migration.nil? + end + end + + if migrations + puts "Foreign key(s) appear to be absent from the database. " \ + "You can add it/them using the following migration(s):" + puts migrations.join("\n") + puts "To disable this warning, add the class name(s) of the model(s) to EXCLUDED_MODELS " \ + "in /spec/models/missing_foreign_keys_spec.rb" + end + + migrations + end + + def process_association(model_class, association, previous_models) + return if association.options[:polymorphic] + + foreign_key_table_name = determine_foreign_key_table_name(model_class, association) + foreign_key_column = "#{association.options[:foreign_key] || association.name}_id" + + # Filter out duplicate migrations + return if duplicate_migration?(model_class, foreign_key_table_name, previous_models) + + previous_models[model_class.table_name] ||= [] + previous_models[model_class.table_name] << foreign_key_table_name + + generate_migration(model_class, association, foreign_key_table_name, foreign_key_column) + end + + def determine_foreign_key_table_name(model_class, association) + if association.options[:class_name] + class_name = association.options[:class_name].underscore.parameterize + foreign_key_table_name = class_name.tableize + else + foreign_key_table_name = association.class_name.underscore.parameterize.tableize + namespace = model_class.name.deconstantize + + unless association.class_name.deconstantize == namespace || namespace == "" || + ActiveRecord::Base.connection.table_exists?(foreign_key_table_name) + foreign_key_table_name = "#{namespace.underscore}_#{foreign_key_table_name}" + end + end + + foreign_key_table_name + end + + def generate_migration(model_class, _association, foreign_key_table_name, foreign_key_column) + migration_name = "add_foreign_key_to_#{model_class.table_name}_#{foreign_key_table_name}" + migration_class_name = migration_name.camelize + + <<~MIGRATION + class #{migration_class_name} < ActiveRecord::Migration[6.0] + def change + add_foreign_key :#{model_class.table_name}, :#{foreign_key_table_name}, column: :#{foreign_key_column} + end + end + MIGRATION + end + + def duplicate_migration?(model_class, foreign_key_table_name, previous_models) + model_class.connection.foreign_key_exists?(model_class.table_name, foreign_key_table_name) || + previous_models[model_class.table_name]&.include?(foreign_key_table_name) + end +end