Polymorphic relationships in Rails that keep your database happy with almost no setup
If you are using Bundler, you can add the gem to your Gemfile:
# with Rails >= 4.2
gem 'polymorpheus'
Or:
# with Rails < 4.2
gem 'foreigner'
gem 'polymorpheus'
-
What is polymorphism? Rails Guides has a great overview of what polymorphic relationships are and how Rails handles them
-
If you don't think database constraints are important then here is a presentation that might change your mind. If you're still not convinced, this gem won't be relevant to you.
-
What's wrong with Rails' built-in approach to polymorphism? Using Rails, polymorphism is implemented in the database using a
type
column and anid
column, where theid
column references one of multiple other tables, depending on thetype
. This violates the basic principle that one column in a database should mean to one thing, and it prevents us from setting up any sort of database constraint on theid
column.
We'll outline the use case to mirror the example outline in the Rails Guides:
- You have a
Picture
object that can belong to anImageable
, where anImageable
is a polymorphic representation of either anEmployee
or aProduct
.
With Polymorpheus, you would define this relationship as follows:
Database migration
class SetUpPicturesTable < ActiveRecord::Migration
def self.up
create_table :pictures do |t|
t.integer :employee_id
t.integer :product_id
end
add_polymorphic_constraints 'pictures',
{ 'employee_id' => 'employees.id',
'product_id' => 'products.id' }
end
def self.down
remove_polymorphic_constraints 'pictures',
{ 'employee_id' => 'employees.id',
'product_id' => 'products.id' }
drop_table :pictures
end
end
ActiveRecord model definitions
class Picture < ActiveRecord::Base
# takes same additional options as belongs_to
belongs_to_polymorphic :employee, :product, :as => :imageable
validates_polymorph :imageable
end
class Employee < ActiveRecord::Base
# takes same additional options as has_many
has_many_as_polymorph :pictures, inverse_of: employee
end
class Product < ActiveRecord::Base
has_many_as_polymorph :pictures
end
That's it!
Now let's review what we've done.
- Instead of
imageable_type
andimageable_id
columns in the pictures table, we've created explicit columns for theemployee_id
andproduct_id
- The
add_polymorphic_constraints
call takes care of all of the database constraints you need, without you needing to worry about sql! Specifically it:- Creates foreign key relationships in the database as specified. So in this
example, we have specified that the
employee_id
column in thepictures
table should have a foreign key constraint with theid
column of theemployees
table. - Creates appropriate triggers in our database that make sure that exactly one
or the other of
employee_id
orproduct_id
are specified for a given record. An exception will be raised if you try to save a database record that contains both or none of them.
- Creates foreign key relationships in the database as specified. So in this
example, we have specified that the
- Options for migrations: There are options to customize the foreign keys generated by Polymorpheus and add uniqueness constraints. For more info on this, read the wiki entry.
- The
belongs_to_polymorphic
declaration in thePicture
class specifies the polymorphic relationship. It provides all of the same methods that Rails does for its built-in polymorphic relationships, plus a couple additional features. See the Interface section below. validates_polymorph
declaration: checks that exactly one of the possible polymorphic relationships is specified. In this example, either anemployee_id
orproduct_id
must be specified -- if both are nil or if both are non-nil a validation error will be added to the object.- The
has_many_as_polymorph
declaration generates a normal Railshas_many
declaration, but adds a constraint that ensures that the correct records are retrieved. This means you can still use the same conditions with it that you would use with ahas_many
association (such as:order
,:class_name
, etc.). Specifically, thehas_many_as_polymorph
declaration in theEmployee
class of the example above is equivalant tohas_many :pictures, { product_id: nil }
and thehas_many_as_polymorph
declaration in theProduct
class is equivalent tohas_many :pictures, { employee_id: nil }
- Currently the gem only supports MySQL. Please feel free to fork and submit a (well-tested) pull request if you want to add Postgres support.
- This gem is tested and has been tested for Rails 2.3.8, 3.0.x, 3.1.x, 3.2.x, and 4.0.0
- For Rails 3.1+, you'll still need to use
up
anddown
methods in your migrations.
The nice thing about Polymorpheus is that under the hood it builds on top of the Rails conventions you're already used to which means that you can interface with your polymorphic relationships in simple, familiar ways. It also lets you introspect on the polymorphic associations.
Let's use the example above to illustrate.
sam = Employee.create(name: 'Sam')
nintendo = Product.create(name: 'Nintendo')
pic = Picture.new
=> #<Picture id: nil, employee_id: nil, product_id: nil>
pic.imageable
=> nil
# The following two options are equivalent, just as they are normally with
# ActiveRecord:
# pic.employee = sam
# pic.employee_id = sam.id
# If we specify an employee, the imageable getter method will return that employee:
pic.employee = sam;
pic.imageable
=> #<Employee id: 1, name: "Sam">
pic.employee
=> #<Employee id: 1, name: "Sam">
pic.product
=> nil
# If we specify a product, the imageable getting will return that product:
Picture.new(product: nintendo).imageable
=> #<Product id: 1, name: "Nintendo">
# But, if we specify an employee and a product, the getter will know this makes
# no sense and return nil for the imageable:
Picture.new(employee: sam, product: nintendo).imageable
=> nil
# A `polymorpheus` instance method is attached to your model that allows you
# to introspect:
pic.polymorpheus.associations
=> [
#<Polymorpheus::InterfaceBuilder::Association:0x007f88b5528b00 @name="employee">,
#<Polymorpheus::InterfaceBuilder::Association:0x007f88b55289c0 @name="picture">
]
pic.polymorpheus.associations.map(&:name)
=> ["employee", "product"]
pic.polymorpheus.associations.map(&:key)
=> ["employee_id", "product_id"]
pic.polymorpheus.active_association
=> #<Polymorpheus::InterfaceBuilder::Association:0x007f88b5528b00 @name="employee">,
pic.polymorpheus.query_condition
=> {"employee_id"=>"1"}
- This gem was written by Barun Singh
- It uses the Foreigner gem under the hood for Rails < 4.2.
polymorpheus is Copyright © 2011-2015 Barun Singh and WegoWise. It is free software, and may be redistributed under the terms specified in the LICENSE file.