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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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:

1
2
3
4
5
6
7
8
9
10
11
class Post < ApplicationRecord
  include Imageable

  # Other stuff
end

class Event < ApplicationRecord
  include Imageable

  # Other stuff
end