Polymorphic Paperclip Attachments with Partial ActiveRecord index
A common pattern in Rails applications with Image Attachments is to create a
separate model for the Image and use it in Polymorphic relationships with
other models. Since more often than not an object has more than one image
attached with a default one, the classic way of storing a default_image_id
on the parent model can quickly result in code duplication and spaghetti
through out the rails application.
A better solution is to specify a default
boolean on the Image model itself
and use partial indexes for proper scoping.
To DRY up the code even further, we can use the Concern Model pattern.
This Blog Post on Viget is an excellent primer on using Concerns. I’m
using Rails 5, Postgres & Paperclip and will use the class
name ImageAttachment
through out the article.
Start by creating a migration for your Image model:
# db/migrations/xxxxxxxxxxxxxx_create_image_attachment.rb
class CreateImageAttachments < ActiveRecord::Migration[5.0]
def change
create_table :image_attachments do |t|
t.belongs_to :imageable, polymorphic: true
t.attachment :data
t.boolean :default, default: false
t.timestamps
end
add_index :image_attachments, [:imageable_id, :imageable_type, :default],
unique: true,
where: '"default" = TRUE',
name: :unique_on_imageable_default
end
end
This creates an ordinary table with a Paperclip attachment and a default
boolean field. But notice the specifics of the unique index
above; it’s a
partial index only on the rows where the value of default
is true. This
validates that there is only one default image for any given object at a time.
In the ImageAttachment
model itself, we can set a conditional uniqueness
on the imageable
scope. This validates the same in the Rails application
too:
# app/models/image_attachment.rb
class ImageAttachment < ApplicationRecord
belongs_to :imageable, polymorphic: true
has_attached_file :data,
styles: { thumb: '120x120#', medium: '600x600>' },
convert_options: { thumb: '-quality 75 -strip', medium: '-quality 90 -strip' }
validates_attachment_presence :data
validates_attachment_size :data, less_than: PAPERCLIP_IMAGE_SIZE_LIMIT
validates_attachment_content_type :data, content_type: PAPERCLIP_IMAGE_CONTENT_TYPE
# Conditional Uniqueness validation on the belongs_to scope
validates :default, uniqueness: { scope: :imageable }, if: :default?
# Methods to set/unset the default image
def undefault!
update(default: false)
end
def default!
imageable.default_image.undefault! if imageable.default_image
update(default: true)
end
end
Finally, we create an Imageable
concern, which defines the relationship
with the ImageAttachment
model and provides a default_image
method:
# app/models/concerns/imageable.rb
module Imageable
extend ActiveSupport::Concern
included do
has_many :image_attachments, as: :imageable, dependent: :destroy
alias_attribute :images, :image_attachments
end
def default_image
images.find_by(default: true) || images.first
end
end
Now, to be able to “attach images” to any model, all you have to do is to
include Imageable
:
class Post < ApplicationRecord
include Imageable
# Other stuff
end
class Event < ApplicationRecord
include Imageable
# Other stuff
end