ruby-on-railsrubyactiverecordpolymorphic-associations

How to automatically derive foreign key column names with polymorphic associations?


In my Ruby on Rails 7 application I have a large Account model with various has_many associations (most of them are left out for brevity here):

class Account < ApplicationRecord

  has_one   :address,         :dependent => :destroy, :as => :addressable # Polymorphic!
  has_many  :articles,        :dependent => :destroy
  has_many  :bank_accounts,   :dependent => :destroy, :as => :bankable # Polymorphic!
  has_many  :clients,         :dependent => :destroy
  has_many  :invoices,        :dependent => :destroy
  has_many  :languages,       :dependent => :destroy
  has_many  :payments,        :dependent => :destroy
  has_many  :projects,        :dependent => :destroy
  has_many  :reminders,       :dependent => :destroy
  has_many  :tasks,           :dependent => :destroy
  has_many  :users,           :dependent => :destroy

end

Users may create a guest account and then later switch it over to a registered account.

I have created a Service object to achieve this task:

class Accounts::Move < ApplicationService

  def initialize(account)
    @account = account
    @guest_account = @account.guest_account
  end

  def call
    if @guest_account
      update_dependencies
      @guest_account.delete
      reset_guest_account_id
    end
  end

private

  def update_dependencies
    dependencies.each do |dependency|
      dependency.constantize.where(:account_id => @guest_account.id).update_all(:account_id => @account.id) # Not working with polymorphic associations!
    end
  end

  def dependencies
    Account.reflect_on_all_associations.map(&:class_name)
  end

  def reset_guest_account_id
    @account.update_column(:guest_account_id, nil)
  end

end

This works well for all associations that have an account_id column, however it doesn't work for polymorphic associations such as address and bank_accounts.

How can I switch over those too without having to hardcode the column names of the foreign keys (addressable_id and bankable_id in this case, for example)?


Solution

  • Instead of hardcoding the foreign keys (on any type of assocation) you could use ActiveRecords ability to reflect on assocations:

    # Do not use the scope resolution operator for defining namespaces - it leads to autoloading bugs
    # and surprising constant lookups
    module Accounts
      class Move < ApplicationService
        def initialize(account)
          @account = account
          @guest_account = @account.guest_account
        end
      
        def call
          # Why is this even needed? 
          # You should move this filtering up to where you enqueue the service
          if @guest_account
            update_dependencies
            reset_guest_account_id # Null the reference first
            @guest_account.delete
          end
        end
      
        private
      
        def update_dependencies
          # Instead of iterating accross an array of strings it's
          # ActiveRecord::Reflection::AssociationReflection instances
          dependencies.each do |reflection|
            scope = {
              reflection.foreign_key => @guest_account.id,
              reflection.type => reflection.type ? 'Account' : nil
            }.compact
            reflection.klass.where(scope)
                      .update_all(reflection.foreign_key => @account.id)
          end
        end
      
        def dependencies
          Account.reflect_on_all_associations(:has_many)
        end
      
        def reset_guest_account_id
          @account.update_column(:guest_account_id, nil)
        end
      end
    end
    

    However I have to strongly discourage you from actually doing this.

    Looping over the assocations of a class like this and automatically updating a bunch of rows feels needlessly reckless and dangerous. Using a whitelist would be far better.

    Another big issue here is the complete lack of error handling - if any of the updates here fails it's going to leave your data in a very messy state.