Rails: Many to many polymorphic relationships

2019-03-07 19:46发布

See comments for updates.

I've been struggling to get a clear and straight-forward answer on this one, I'm hoping this time I'll get it! :D I definitely have a lot to learn still with Rails, however I do understand the problem I'm facing and would really appreciate additional help.

  • I have a model called "Task".
  • I have an abstract model called "Target".
  • I would like to relate multiple instances of subclasses of Target to Task.
  • I am not using single table inheritance.
  • I would like to query the polymorphic relationship to return a mixed result set of subclasses of Target.
  • I would like to query individual instances of subclasses of Target to obtain tasks that they are in a relationship with.

So, I figure a polymorphic many to many relationship between Tasks and subclasses of Targets is in order. In more detail, I will be able to do things like this in the console (and of course elsewhere):

task = Task.find(1)
task.targets
[...array of all the subclasses of Target here...]

But! Assuming models "Store", "Software", "Office", "Vehicle", which are all subclasses of "Target" exist, it would be nice to also traverse the relationship in the other direction:

store = Store.find(1)
store.tasks
[...array of all the Tasks this Store is related to...]
software = Software.find(18)
software.tasks
[...array of all the Tasks this Software is related to...]

The database tables implied by polymorphic relationships appears to be capable of doing this traversal, but I see some recurring themes in trying to find an answer which to me defeat the spirit of polymorphic relationships:

  • Using my example still, people appear to want to define Store, Software, Office, Vehicle in Task, which we can tell right away isn't a polymorphic relationship as it only returns one type of model.
  • Similar to the last point, people still want to define Store, Software, Office and Vehicle in Task in one way shape or form. The important bit here is that the relationship is blind to the subclassing. My polymorphs will initially only be interacted with as Targets, not as their individual subclass types. Defining each subclass in Task again starts to eat away at the purpose of the polymorphic relationship.
  • I see that a model for the join table might be in order, that seems somewhat correct to me except that it adds some complexity I assumed Rails would be willing to do away with. I plea inexperience on this one.

It seems to be a small hole in either rails functionality or the collective community knowledge. So hopefully stackoverflow can chronicle my search for the answer!

Thanks to everyone who help!

7条回答
霸刀☆藐视天下
2楼-- · 2019-03-07 19:55

Have you pursued that brute force approach:

class Task 
  has_many :stores
  has_many :softwares
  has_many :offices
  has_many :vehicles

  def targets
    stores + softwares + offices + vehicles
  end
  ...

It may not be that elegant, but to be honest it's not that verbose, and there is nothing inherently inefficient about the code.

查看更多
Emotional °昔
3楼-- · 2019-03-07 19:57

Although the answer proposed by by SFEley is great, there a some flaws:

  • The retrieval of tasks from target (Store/Vehicle) works, but the backwards wont. That is basically because you can't traverse a :through association to a polymorphic data type because the SQL can't tell what table it's in.
  • Every model with a :through association need a direct association with the intermediate table
  • The :through Assignment association should be in plural
  • The :as statement wont work together with :through, you need to specify it first with the direct association needed with the intermediate table

With that in mind, my simplest solution would be:

class Assignment < ActiveRecord::Base
  belongs_to :task
  belongs_to :target, :polymorphic => true
end

class Task < ActiveRecord::Base
  has_many :assignments
  # acts as the the 'has_many targets' needed
  def targets
    assignments.map {|x| x.target}
  end
end

class Store < ActiveRecord::Base
  has_many :assignments, as: :target
  has_many :tasks, :through => :assignment
end

class Vehicle < ActiveRecord::Base
  has_many :assignments, as: :target
  has_many :tasks, :through => :assignment, :as => :target
end

References: http://blog.hasmanythrough.com/2006/4/3/polymorphic-through

查看更多
家丑人穷心不美
4楼-- · 2019-03-07 19:59

Using STI:

class Task < ActiveRecord::Base
end

class StoreTask < Task
  belongs_to :store, :foreign_key => "target_id"
end

class VehicleTask < Task
  belongs_to :vehicle, :foreign_key => "target_id"
end

class Store < ActiveRecord::Base
  has_many :tasks, :class_name => "StoreTask", :foreign_key => "target_id"
end

class Vehicle < ActiveRecord::Base
  has_many :tasks, :class_name => "VehicleTask", :foreign_key => "target_id"
end

In your databse you'll need: Task type:string and Task target_id:integer

The advantage is that now you have a through model for each task type which can be specific.

See also STI and polymorphic model together

Cheers!

查看更多
祖国的老花朵
5楼-- · 2019-03-07 20:03

The has_many_polymorphs solution you mention isn't that bad.

class Task < ActiveRecord::Base
  has_many_polymorphs :targets, :from => [:store, :software, :office, :vehicle]
end

Seems to do everything you want.

It provides the following methods:

to Task:

t = Task.first
t.targets   # Mixed collection of all targets associated with task t
t.stores    # Collection of stores associated with task t
t.softwares # same but for software
t.offices   # same but for office
t.vehicles  # same but for vehicles

to Software, Store, Office, Vehicle:

s = Software.first    # works for any of the subtargets.
s.tasks               # lists tasks associated with s

If I'm following the comments correctly, the only remaining problem is that you don't want to have to modify app/models/task.rb every time you create a new type of Subtarget. The Rails way seems to require you to modify two files to create a bidirectional association. has_many_polymorphs only requires you to change the Tasks file. Seems like a win to me. Or at least it would if you didn't have to edit the new Model file anyway.

There are a few ways around this, but they seem like way too much work to avoid changing one file every once in a while. But if you're that dead set against modifying Task yourself to add to the polymorphic relationship, here's my suggestion:

Keep a list of subtargets, I'm going to suggest in lib/subtargets formatted one entry per line that is essentially the table_name.underscore. (Capital letters have an underscore prefixed and then everything is made lowercase)

store
software
office
vehicle

Create config/initializers/subtargets.rb and fill it with this:

SubtargetList = File.open("#{RAILS_ROOT}/lib/subtargets").read.split.reject(&:match(/#/)).map(&:to_sym)

Next you're going to want to either create a custom generator or a new rake task. To generate your new subtarget and add the model name to the subtarget list file, defined above. You'll probably end up doing something bare bones that makes the change and passes the arguments to the standard generator.

Sorry, I don't really feel like walking you through that right now, but here are some resources

Finally replace the list in the has_many_polymorphs declaration with SubtargetList

class Task < ActiveRecord::Base
  has_many_polymorphs :targets, :from => SubtargetList
end

From this point on you could add a new subtarget with

$ script/generate subtarget_model home

And this will automatically update your polymorphic list once you reload your console or restart the production server.

As I said it's a lot of work to automatically update the subtargets list. However, if you do go this route you can tweak the custom generator ensure all the required parts of the subtarget model are there when you generate it.

查看更多
对你真心纯属浪费
6楼-- · 2019-03-07 20:06

I agree with the others I would go for a solution that uses a mixture of STI and delegation would be much easier to implement.

At the heart of your problem is where to store a record of all the subclasses of Target. ActiveRecord chooses the database via the STI model.

You could store them in a class variable in the Target and use the inherited callback to add new ones to it. Then you can dynamically generate the code you'll need from the contents of that array and leverage method_missing.

查看更多
\"骚年 ilove
7楼-- · 2019-03-07 20:10

You can combine polymorphism and has_many :through to get a flexible mapping:

class Assignment < ActiveRecord::Base
  belongs_to :task
  belongs_to :target, :polymorphic => true
end

class Task < ActiveRecord::Base
  has_many :targets, :through => :assignment
end

class Store < ActiveRecord::Base
  has_many :tasks, :through => :assignment, :as => :target
end

class Vehicle < ActiveRecord::Base
  has_many :tasks, :through => :assignment, :as => :target
end

...And so forth.

查看更多
登录 后发表回答