3
votes

My current project allows Doctors to have many Patients with which I can do the following:

dennis = Patient.create
frank = Doctor.create
dennis.update(doctor: frank)
dennis.doctor #=> frank
frank.patients #=> [dennis, ...]

But now I'd like to add a class Hospital, that can also have many patients. I don't want to just add another has_many to the Hostpital class because Patient 'ownership' may change again and eventually my patient model will be littered with foreign key fields, all but one of which will be empty. A polymorphic association appears to be just what I am looking for: a Patient can be 'owned' by either a Doctor or a Hospital:

class Patient < ActiveRecord::Base
  belongs_to :owner, polymorphic: true
end

class Doctor < ActiveRecord::Base
  has_many :patients, as: :owner
end

class Hospital < ActiveRecord::Base
  has_many :patients, as: :owner
end

This allows us to do the following:

dennis = Patient.create
frank = Doctor.create
dennis.update(owner: frank)
dennis.owner #=> frank
frank.patients #=> [dennis, ...]

We cannot, however, call dennis.doctor to return frank. I understand that the owner may not always be an instance of the Doctor class, but much of my current code uses the #doctor and #doctor= methods. So I figured I could just define them:

class Patient < ActiveRecord::Base
  belongs_to :owner, polymorphic: true

  def patient=(patient)
    self.owner_id = patient.id
    self.owner_type = "Patient"
  end

  def patient
    return nil unless self.owner
    self.owner.class == Patient ? self.owner : nil
  end

end

This seemed to work fine, however this association is still not reflected in my database. I have some custom SQL queries that reference patients.doctor. This now throws Mysql2::Error: Unknown column 'patients.doctor' with the polymorphic association.

Is there a better way I could be implementing this? At this point, going back through all of my code and sql queries to change .doctor to .owner would be prohibitively time consuming.

TL;DR Trying to switch from has_many to polymorphic association, but I'd like to keep the convenient getter and setter methods (as well as the associations in my database) provided by the has_many relationship.

Any help is appreciated!

1
For what it is worth, I strongly recommend avoiding polymorphic associations. They are very difficult to enforce in the database (data integrity) because you lose the ability to have foreign key constraints. If you have a foreign key for each possible owner then you can use foreign keys and check constraints. This is done very effectively in my work projects (maybe someday we will make it open-source). We also have getter and setter methods (owner and owner=) like you describe. - Wizard of Ogz

1 Answers

0
votes

Caveat: I don't highly recommend this, because it's decently hacky and relies on skirting around several Rails behaviors, which is error-prone and never very future-proof. E.g. you will also have to override the build_doctor, create_doctor, and create_doctor! methods if you use them, which I have not done below. I also agree with @doctor_of_ogz in that if you can avoid a polymorphic association, you should. It's not clear to me immediately what the best solution would be, but you might want to more heavily consider multiple foreign key columns as it might be worth the extra nils.

But if you don't want to go that route, what you want should be as follows:

class Patient < ActiveRecord::Base
  belongs_to :owner, polymorphic: true
  belongs_to :doctor, ->{joins(:patients).where(patients: {owner_type: "Doctor"})}, foreign_key: "owner_id"
  belongs_to :hospital, ->{joins(:patients).where(patients: {owner_type: "Hospital"})}, foreign_key: "owner_id"

  def doctor(*args)
    owner_type == "Doctor" ? super : nil
  end

  def doctor=(doctor)
    super
    self.owner = doctor
  end

  def hospital(*args)
    owner_type == "Hospital" ? super : nil
  end

  def hospital=(hospital)
    super
    self.owner = hospital
  end
end

Explanation:

The association add a scope on resource_type to allow you to define the association correctly for joins and preloads and the likes. But you need to explicitly add the joins to the scope if you intend on using the instance method patient.doctor (try it without, you'll see what happens).

I've still use method overrides on the doctor and hospital instance methods because if you call patient.hospital, it will return a Hospital it finds one that has a Patient and where the Hospital's id matches the current patient's owner_id, even if patient.owner_type == "Doctor". Rails is generating a query on the Hospital model not considering the instance we're calling it from. This is where it begins to feel hacky and not recommended, but the override solves that issue simply by switching on self.owner_type as you've already done, but here we can now just use super, which gets you Rails' caching behavior and any other frills built into Rails' association getter methods. Similarly, the setter method doctor= does not set owner_type, so I overrode it again similarly to your existing method, but with super. Note that I call super first in case a bad argument is supplied (e.g. you pass in a Hospital to doctor=, which will raise a ActiveRecord::AssociationTypeMismatch), in which case it won't change self.owner_type. Then, rather than simply saying self.owner_type = "Doctor", I opted for self.owner = doctor, which does set owner_type, and also knows to fill the cache of the :owner association

I wrote a little more detail about this solution here