Search code examples
ruby-on-railsactiverecordrelationshipactivesupport-concern

Can I add both sides of a has_many and belongs_to relationship in a concern?


Edit: In retrospect this isn't that great of an idea. You are putting functionality that belongs to ZipWithCBSA into the models of others. The models receiving the concern act as they are supposed to, and the fact that ZipWithCBSA responds to :store_locations should be obvious in some capacity from ZipWithCBSA. It has nothing to do with the other models/concern. Props to Robert Nubel for making this obvious with his potential solutions.

Is it possible to both has_many and belongs_to relationships in a single concern?

Overview

I have a table ZipWithCBSA that essentially includes a bunch of zip code meta information.

I have two models that have zip codes: StoreLocation and PriceSheetLocation. Essentially:

class ZipWithCBSA < ActiveRecord::Base
  self.primary_key = :zip
  has_many :store_locations, foreign_key: :zip
  has_many :price_sheet_locations, foreign_key: :zip
end

class StoreLocation< ActiveRecord::Base
  belongs_to :zip_with_CBSA, foreign_key: :zip
  ...
end

class PriceSheetLocation < ActiveRecord::Base
  belongs_to :zip_with_CBSA, foreign_key: :zip
  ...
end

There are two properties from ZipWithCBSA that I always want returned with my other models, including on #index pages. To prevent joining this table for each item every time I query it, I want to cache two of these fields into the models themselves -- i.e.

# quick schema overview~~~
ZipWithCBSA
  - zip
  - cbsa_name
  - cbsa_state
  - some_other_stuff
  - that_I_usually_dont_want
  - but_still_need_access_to_occasionally

PriceSheetLocation
  - store
  - zip
  - cbsa_name
  - cbsa_state

StoreLocation
  - zip
  - generic_typical
  - location_address_stuff
  - cbsa_name
  - cbsa_state

So, I've added

after_save :store_cbsa_data_locally, if: ->(obj){ obj.zip_changed? }
private
def store_cbsa_data_locally
  if zip_with_cbsa.present?
    update_column(:cbsa_name, zip_with_cbsa.cbsa_name)
    update_column(:cbsa_state, zip_with_cbsa.cbsa_state)
  else
    update_column(:cbsa_name, nil)
    update_column(:cbsa_state, nil)
  end
end

I'm looking to move these into concerns, so I've done:

# models/concerns/UsesCBSA.rb

module UsesCBSA
  extend ActiveSupport::Concern

  included do
    belongs_to :zip_with_cbsa, foreign_key: 'zip'

    after_save :store_cbsa_data_locally, if: ->(obj){ obj.zip_changed? }

    def store_cbsa_data_locally
      if zip_with_cbsa.present?
        update_column(:cbsa_name, zip_with_cbsa.cbsa_name)
        update_column(:cbsa_state, zip_with_cbsa.cbsa_state)
      else
        update_column(:cbsa_name, nil)
        update_column(:cbsa_state, nil)
      end
    end

    private :store_cbsa_data_locally

  end
end


# ~~models~~
class StoreLocation < ActiveRecord::Base
  include UsesCBSA
end

class PriceSheetLocation < ActiveRecord::Base
  include UsesCBSA
end

This is all working great-- but I still need to manually add the has_many relationships to the ZipWithCBSA model:

class ZipWithCBSA < ActiveRecord::Base
  has_many :store_locations, foreign_key: zip
  has_many :price_sheet_locations, foreign_key: zip
end

Finally! The Question!

Is it possible to re-open ZipWithCBSA and add the has_many relationships from the concern? Or in any other more-automatic way that allows me to specify one single time that these particular series of models are bffs?

I tried

# models/concerns/uses_cbsa.rb
...
def ZipWithCBSA
  has_many self.name.underscore.to_sym, foregin_key: :zip
end
...

and also

# models/concerns/uses_cbsa.rb
...
ZipWithCBSA.has_many self.name.underscore.to_sym, foregin_key: :zip
...

but neither worked. I'm guessing it has to do with the fact that those relationships aren't added during the models own initialization... but is it possible to define the relationship of a model in a separate file?

I'm not very fluent in metaprogramming with Ruby yet.


Solution

  • Your best bet is probably to add the relation onto your ZipWithCBSA model at the time your concern is included into the related models, using class_exec on the model class itself. E.g., inside the included block of your concern, add:

    relation_name = self.table_name
    ZipWithCBSA.class_exec do
      has_many relation_name, foreign_key: :zip
    end