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!