Rails 5: Getting Started with Active Storage

Introduction

When storing static files in Rails, the first toolsets I reach for are 3rd party gems like: CarrierWave or Paperclip (before they deprecated it in favor of Active Storage). Active Storage was introduced with Rails 5.2. Check out the official Rails announcement blog. Active Storage is the Rails way of storing your static files without the need of any 3rd party gems. Without further ado, let's get going.

Setup

First, we need to install Active Storage into a Rails project. I'll be starting a new project, but it can be fit into an existing app in the exact same way. We'll need to run this command on the command line:

rails active_storage:install

After running this command, Rails generates a migration for you. Upon inspection of that migration, it shows that it creates 2 different tables: active_storage_blobs is up first, it contains information about files like metadata, filename, checksum, and so on.

The next table, active_storage_attachments, stores the name of any file attachment as well as the record, which is also a polymorphic association. Why is that important? It allows you to use file attachments on any model without cluttering your model's migrations with table columns specifically for file data.

You'll find the storage configuration settings in the config/storage.yml file:

test:
 service: Disk
 root: <%= Rails.root.join("tmp/storage") %>

local:
 service: Disk
 root: <%= Rails.root.join("storage") %>

This tells us that we will be storing the assets on our local disk, but we are able to configure Active Storage through other services like Amazon S3. These types of configurations are outside the scope of this article, but you can find more configuration options here.

If there isn't any information on the model for storing file data, how does Rails know you want to store files for that model? I'm glad you asked.

Model Generation and Configuration

For this example, I'm going to be implementing a basic blog. I've scaffolded a single model:

rails generate scaffold Post title body:text posted_at:datetime

This will create the model, controller actions, views, tests, and so on for a Post. The post will have a title, body, and posted date.

The migration looks like this:

class CreatePosts < ActiveRecord::Migration[5.2]
 def change
   create_table :posts do |t|
     t.string :title
     t.text :body
     t.datetime :posted_at

     t.timestamps
   end
 end
end

If you are starting a new project, don't forget to run the rails db:create command on the command line. After your database is created and ready to go, run the rails db:migrate command so that the model data is ready to be used by Active Record.

You should see something like the following if the migrations were successful:

$ rails db:migrate
== 20190301190644 CreatePosts: migrating ======================================
-- create_table(:posts)
  -> 0.0121s
== 20190301190644 CreatePosts: migrated (0.0122s) =============================

== 20190301190828 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs)
  -> 0.0153s
-- create_table(:active_storage_attachments)
  -> 0.0200s
== 20190301190828 CreateActiveStorageTables: migrated (0.0356s) ===============

The setup so far has been nothing but normal, everyday, out-of-the-box Rails model creation and migration. Now we've come to the fun part: Active Storage.

Now we will set up the relationships for our Post model. The changes are very straightforward and should reflect the following:

# app/models/post.rb

class Post < ApplicationRecord
  attr_accessor :remove_main_image

  has_rich_text :content
  has_one_attached :main_image
  has_many_attached :other_images
end

We use attr_accessor :remove_main_image to create a read / write property which we can use to check a checkbox on the Post model's form. This gives the user the ability to delete the main image from the post without persisting that property to the database.

We didn't directly give the Post model either of the main_image or other_images database columns in our migration file. These methods come from Active Storage and directly associate the file storage mechanism.

Views and Controllers

Now that we have the model primed and ready, we need a way to submit them through a form. Since I created the scaffold earlier, I'll go ahead and modify the app/views/posts/_form.html.erb file. Rails already put some fields in here for the properties that we put in our model migration file: title, body and posted_at. We need to add the photo properties to the form:

<div class="field">
  <%= form.label :main_image %>
  <%= form.file_field :main_image %>
</div>

<div class="field">
  <%= form.label :other_images %>
  <%= form.file_field :other_images, multiple: true %>
</div>

This tells the form builder that we want to create fields for the photo properties. The form now has two file uploaders that can either upload a single image or multiple images by using the multiple: true option.

There is one catch: if we were to submit the form in the state that it's currently in, it wouldn't work. Due to Rails' strong parameters, the values from the form wouldn't pass through to the Post controller, since we haven't added those properties into the list of accepted Post properties. If you're unfamiliar with Rails' strong parameters, browse their docs to learn more.

Next, we will have to modify the controller to tell Rails' strong params that we are expecting the additional photo properties of the model. In the controller that Rails has generated, you'll find a method similar to this at the bottom of the file:

# app/controllers/posts_controller.rb

def post_params
  params.require(:post).permit(:title, :body, :posted_at, :content, :remove_main_image, :main_image, other_images: [])
end

The post_params method is used to constrain data allowed from a form post, acting as a control mechanism for each property that we want to pass through the form. Notice that :other_images is an array. If you don't specify this, it will try to pass a single value. This is an example of how it's used:

# app/controllers/posts_controller.rb

def create
 @post = Post.new(post_params)
 ...
end

With that configured, we can now send either a single image or a batch of images through the form! Looking at the Rails' log, we can see that the photo data was saved to the database:

ActiveStorage::Blob Create (4.3ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "byte_size", "checksum", "created_at") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "id"  [["key", "6DnDrfSK5uyaoNeP9FmCoHng"], ["filename", "IMG_20190227_105022.jpg"], ["content_type", "image/jpeg"], ["metadata", "{\"identified\":true}"], ["byte_size", 240301], ["checksum", "BL64P3lrskB+Fw68Yim94g=="], ["created_at", "2019-03-01 20:57:14.212316"]]

...

ActiveStorage::Attachment Create (1.5ms)  INSERT INTO "active_storage_attachments" ("name", "record_type", "record_id", "blob_id", "created_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["name", "main_image"], ["record_type", "Post"], ["record_id", 1], ["blob_id", 1], ["created_at", "2019-03-01 20:57:14.250964"]]

...

Post Update (0.5ms)  UPDATE "post" SET "updated_at" = $1 WHERE "post"."id" = $2  [["updated_at", "2019-03-01 20:57:14.253611"], ["id", 1]]

As you can see, it inserts the blob data, then the attachments, and finally updates the Post model.

After the form is submitted and we are redirected back to the app/views/posts/show.html.erb page, we initially don't see any photo information. Let's update the show template for the Post:

<!-- app/views/posts/show.html.erb -->

<% if @post.main_image.attached? %>
 <%= image_tag @post.main_image %>
<% end %>

We first check to make sure that a main image attached. If so, then use an image_tag to show the photo and voilĂ ! - the photo is now available on the page!

If you used the other_images file field uploader, we could show those images as well:

<!-- app/views/posts/show.html.erb -->

<% @post.other_images.each do |other| %>
  <%= image_tag other %>
<% end %>

If there are any photos in the other_images array and they are attached to the Post, show the images with another image_tag. The method is the same for main_image as it is for the other_images property to make sure it's attached to the model. That's what I call handy!

SQL Optimization

Loading a post right now generates what is called an N+1 query. What this means is that it will do a query for each image on the post. Imagine if you had 13 items in the other_images array! To make sure we're getting all of the photos in a single query, update the post controller's set_post method to:

# app/controllers/posts_controller.rb

def set_post
  @post = Post.with_attached_other_images.find(params[:id])
end

Originally, if you have many images attached and without the use of with_attached_other_images, the logs look something like this:

ActiveStorage::Attachment Load (0.7ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3  [["record_id", 1], ["record_type", "Post"], ["name", "other_images"]]

ActiveStorage::Blob Load (0.4ms)  SELECT  "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]

ActiveStorage::Blob Load (0.4ms)  SELECT  "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]

ActiveStorage::Blob Load (0.2ms)  SELECT  "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]

After using these methods, the logs should looks more like this:

ActiveStorage::Attachment Load (0.3ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3  [["record_type", "Post"], ["name", "other_images"], ["record_id", 1]]

ActiveStorage::Blob Load (0.6ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN ($1, $2, $3)  [["id", 1], ["id", 2], ["id", 3]]

This makes sure that every time we find a Post object, we are loading the blobs for the images all in one shot using IN within the SQL query instead of doing 3 separate queries! Under the hood, these methods are short for something similar to:

Post.includes("#{self.other_images}"_attachment: :blob)

Post does have a method called with_attached_main_image, but that can only be a single image. It's only beneficial if we're working with an array of images. So that method doesn't do anything for us, in this case.

Conclusion

In this post we learned how to set up a model with Active Storage from Rails 5.2. With Active Storage, everything is painless, as we let Rails do its magic behind the scenes. We also learned that not only can we assign a single static asset to a model, but multiple at once, if the need arises.

This just scratches the surface of what is possible with Active Storage. It is not limited to images, but can also be used for other static file types like PDFs. Please visit the Rails Guides for more information about the different use cases, other neat tricks like doing JavaScript callbacks on uploading events, and 3rd party server integrations like Amazon or Azure.

You can find all of the code mentioned in this blog post on my GitHub account. Please feel free to fork the project to see what you can do with Active Storage!


Category: Development