Discover the lesser-known parts of the next major framework upgrade, appealing to mature applications that have been around for a while. Instead of focusing on “greatest hits,” we will walk you through B-sides and rarities that make new release enjoyable in subtler ways.
While the most-advertised Rails 6 features like Action Mailbox and Action Text steal all the spotlight, it is unlikely that a real-life Rails application that has been around for a while will benefit from the ease of building WYSIWYG text editors right after the upgrade.
At the same time, less flashy features like multiple databases support or parallel testing can bring immediate gains to your productivity—and Rails 6 has enough of those to offer if you know where to look.
I have been following Rails development closely since I started working on Action Cable improvements a few years ago, reading between the lines of countless pull requests. I also had a privilege to rewrite a fair-sized production Rails 4 application in Rails 6, months before the release candidate came out.
I am also a music enthusiast, and watching the big framework getting ready for release is akin to witnessing a production of a musical record: some bits and pieces are destined for heavy rotation while others become B-sides or stay around as rarities cherished by fans.
In this post, I will uncover the hidden gems of the upcoming release: some of them brand new, some of them have been awaiting their turn for years before finally being merged into Rails 6, some of them still exist only as PRs and are yet to become part of upcoming Rails 6.x updates.
Action Cable testing
Action Cable was a major feature of Rails 5 that enabled WebSockets support out of the box, JavaScript library included. It followed the “Rails way” to the letter with convention over configuration and a friendly syntax, but the proponents of the test-driven approach were left hanging: no way to write tests for your channels was officially offered.
I saw an opportunity to fix that with a pull request that I opened back in the day: it was bound for Rails 5 but did not make it to the final tracklist. Instead, it has been released as an EP (action-cable-testing gem) and finally merged into Rails 6 three years after.
Now when you run rails new
for your Rails 6 project (without --skip-action-cable
) you will get the test/channels
folders in addition to app/channels
.
So what should we test in Action Cable? Take a look at these examples from the real-world application.
For Action Cable connections, you might want to cover your authentication related logic:
# spec/channels/application_cable/connection_spec.rb
require "rails_helper"
# `type: :channel` adds Action Cable testing helpers
# Currently comes with action-testing-cable gem,
# but should come included with RSpec 4
RSpec.describe ApplicationCable::Connection, type: :channel do
let(:user) { create(:user) }
it "successfully connects with cookies" do
# set "virtual" request cookies
cookies.signed[:user] = user.id
# `connect` method represents the websocket client
# connection to the server
connect "/websocket"
# and now we can check that the identifier was set correctly
expect(connection.current_user).to eq user
end
it "rejects connection without cookies" do
# test that the connection is rejected if no cookie provided
expect { connect "/websocket" }.to have_rejected_connection
end
it "rejects connection for unexistent user" do
cookies.signed[:user] = -1
expect { connect "/websocket" }.to have_rejected_connection
end
end
With other Action Cable primitives, channels, testing becomes even more interesting, as we can think of channels as of controllers for our WebSockets.
In this example, we will test a PresenceChannel
class that is used in a real-world application to accurately track user activity across pages. These are the testing scenarios for the #subscribe
method:
- When a user connects to the channel they must be registered with a presence tracking system
- When a user connects to the channel they must be subscribed to the corresponding stream to receive notifications
- When a user connects to the channel the “User Joined” notification must be sent to the stream.
And here is how we write these tests using brand new Action Cable testing utilities:
require "rails_helper"
RSpec.describe PresenceChannel, type: :channel do
# `let_it_be` helper is provided by `test-prof` gem
let_it_be(:projectschool) { create(:project) }
let_it_be(:user) { create(:user, project: project) }
before do
# `stub_connection` initializes the Connection instance with
# the provided identifiers
stub_connection current_user: user
end
describe "#subscribe" do
subject do
# `subscribe` helper performs the subscription action
# to the described channel
subscribe
# `subscription` is a instance of the subscribed channel
subscription
end
it "subscribes to the presence stream" do
expect(subject).to be_confirmed
expect(subject).to have_stream_for(project)
end
it "adds current user to the online list" do
subject
# Presence::OnlineUsers is our custom backend implementation
# for storing presence data
expect(Presence::OnlineUsers.for(project)).to match_array([user])
end
it "sends online notification" do
expect { subject }
.to have_broadcasted_to(project)
.with(
type: "presence",
event: "user-presence-changed",
user_id: user.id,
status: "online"
)
end
end
# and almost identical scenarios for `unsubscribe` action
describe "#subscribe" do
before do
# we must subscribe first before calling unsubscribe
subscribe
end
it "removes current user from the online list" do
expect(Presence::OnlineUsers.for(project)).to match_array([user])
unsubscribe
expect(Presence::OnlineUsers.for(project)).to eq([])
end
it "sends offline notification" do
expect { unsubscribe }
.to have_broadcasted_to(project)
.with(
type: "presence",
event: "user-presence-changed",
user_id: user.id,
status: "offline"
)
end
end
end
Feel free to go ahead and add missing Action Cable tests to your application!
Active Storage: Peek into the future
Active Storage is a new framework that has joined Rails family in version 5.2.
I have started using Active Storage since Rails 6 beta1: and even though it has some killer features (like the support for direct uploads out of the box), there are still a lot of rough edges.
Good news: Active Storage evolves quite quickly and improvements are being proposed all the time. Bad news: the PRs we are most interested in will not make it into the first stable Rails 6 release. You can, however, scout the proposed changes and in the meantime use alternative implementations for:
- Attachment size and content type validations. For now, implemented in active_storage_validations gem.
- Using different services with different attachments. When this gets merged you’ll be able to use different services for different attachments (that includes, for example, using different S3 buckets for different models):
class User < ActiveRecord::Base
has_one_attached :avatar, service: :s3
has_one_attached :contract, service: :super_encrypted_s3
end
-
Serving attachments through a proxy, and not through redirects as it is done now. It will allow for an easier CDN setup and ultimately permit to serve user-uploaded assets faster. A separate PR proposes a public_service_url method that can be used for the same purpose.
-
Naming variants. Currently, you have to specify the exact options when creating an attachment variant (e.g.,
user.avatar.variant(resize_to_limit: "50x50")
. Named variants would allow you to writeuser.avatar.variant(:thumb)
instead.
Insert all (active) records!
With Rails 6, Active Record supports bulk inserts.
Surprisingly, this feature hasn’t been proposed before but we had a few gems for that (activerecord-import being the most popular one).
Inserting many records at once is obviously much more efficient than saving one record at a time:
- we only need one SQL query;
- we don’t need to instantiate a model object (which is not free in terms of memory usage).
One major trade-off: #insert_all
method does not invoke neither callbacks nor validations, so use it with care!
The side-effect of this feature is the out-of-the-box support for UPSERT
statement offered by most relational databases (e.g., PostgreSQL). Think of it as INSERT
-or-UPDATE
: if the record you are trying to insert triggers a uniqueness constraint conflict, you can fallback to updating the existing record instead of failing with exception.
Let me show you some code from the project I am working on right now. We have a “mass invite” feature backed by the Invitation(user_id, event_id, rsvp:bool, disposable:bool)
model. When a user invites other users to the event, we create an invitation record for each user that hasn’t been invited yet. And if a user has been already invited, we want to update the invitation properties (that’s where UPSERT
does the trick):
Invitation.pg_batch_insert(
columns, # list of columns to insert
values, # list of values (array of arrays)
on_conflict:
# we have a uniqueness constraint for (user_id, event_id) pair
"(user_id, event_id) DO UPDATE " \
"SET disposable = (events.disposable AND EXCLUDED.disposable), " \
"rsvp = (events.rsvp OR EXCLUDED.rsvp)",
returning: "user_id, (xmax = '0') as inserted"
)
This code uses a mixin that we wrote a while ago and it has helped us a lot.
Now we don’t need it anymore: Rails 6 gives us insert_all
and upsert_all
methods:
# Same functionality as above
Invitation.upsert_all(
# NOTE: Rails expects array of hashes as input
values.map { |v| columns.zip(v).to_h },
unique_by: %i[event_id user_id],
on_duplicate: Arel.sql(
"disposable = (events.disposable AND EXCLUDED.disposable), " \
"rsvp = (events.rsvp OR EXCLUDED.rsvp)"
),
returning: Arel.sql("user_id, (xmax = '0') as inserted")
)
Note that passing raw SQL to the on_duplicate
and returning
options used in the example are a part of the PR that didn’t make it into Rails 6 series, and only have been merged into Rails 7.
“Dirty” store accessors
Action Cable testing was waiting for the showtime for three long years, but this little feature has definitely broken the record: “dirty” tracking methods for store accessors were first proposed in 2015.
Now the PR is merged and in Rails 6 you can track changes to store attributes the same way you are used to when dealing with “vanilla” Active Record attributes:
class Account < ApplicationRecord
store_accessor :settings, :color
end
acc = Account.new
acc.color_changed? #=> false
acc.color = "red-n-white"
acc.color_changed? #=> true
There is some history behind this feature.
Rails has provided a way to track “dirty” (having yet-unsaved changes) attributes for Active Record models since Rails 2.1 introduced in 2008. Version 3.2 added the so-called store accessors as a way to create readers/writers for the serialized data stored in a single column (often as a JSON).
But the true power of store accessors was unleashed after PostgreSQL added support for a JSONB data type, which provides an efficient, compact and indexable way to store unstructured data.
The previous way to track changes with store accessors looked something like this (example taken from a real-world code base):
class RangeQuestion < ActiveRecord::Base
after_commit :recalculate_answers_scores, on: :update, if: :answer_was_changed?
# range questions expect a correct answer to
# be within `min` and `max` values
store_accessor :options, :min, :max
# btw, store accessor could be validated the same way
# as regular attributes
validates :min, :max, presence: true, numericality: { only_integer: true }
end
The answer_was_changed?
method had to track changes on the options
attribute as a whole and thus looked cumbersome:
def answer_was_changed?
# see ActiveRecord::AttributeMethods::Dirty
return false if saved_change_to_attribute?("options")
prev_options = saved_change_to_attribute("options").first
prev_options.dig("min") != min || prev_options.dig("max") != max
end
As we had to do the same in many other places in the code, I came up with an idea to extend *_changed?
methods to store accessors. Starting with Rails 6, you can just do this:
def answer_was_changed?
saved_change_to_min? || saved_change_to_max?
end
Looks much better, right? So, why did a fairly simple feature have to wait so long? The main reason is that Sean Griffin, one of the core contributors at a time, wanted to promote store accessors to full-featured attributes backed by not very well-known Attributes API. Unfortunately, that did not happen and likely will not happen in the near future—Sean has recently left the Rails Core team.
More Active Record goodies
- Support for optimizer hints. This is how you limit the max execution time for a given query with MySQL:
User.optimizer_hints("MAX_EXECUTION_TIME(5000)").all
#=> SELECT /*+ MAX_EXECUTION_TIME(5000) */ `users`.* FROM `users`
By the way, Active Record now recognizes the execution timeout error and raises the StatementTimeout
exception. Feel free to catch it!
- You can now add comments to your queries:
Post.for_user(user).annotate("fetching posts for user ##{user.id}").to_sql
#=> SELECT "posts".* FROM "posts" WHERE ... /* fetching posts for user #123 */
- Negative scopes for enums are now generated automatically:
class User < ApplicationRecord
enum role: {
member: "member",
manager: "manager",
admin: "admin"
}
end
User.not_member == User.where.not(role: :member) #=> true
-
A bunch of shortcuts have been added: destroy_by, delete_by, touch_all, and reselect
-
You don’t have to do
Model.delete_all
at the start of your seeds anymore,db:truncate_all
will clear all tables without dropping them:
# Truncate all the tables in the database
# NOTE: `be` is an alias for `bundle exec`, feel free to use it!
$ be rails db:truncate_all
# Or truncate all and run db:seed in one command
$ be rails db:seed:replant
That is especially useful if you want to re-seed your staging or review app without dropping the database (one shouldn’t try to re-seed the production DB, shouldn’t they?).
Per-environment credentials
Since the days of Rails 5.2, credentials have been named a new “Rails way” to deal with sensitive information with a promise to get rid of infamous .env
files once and for all. With credentials, encrypted keys for third-party services can be checked directly into the source control.
Up until now, however, Rails used the same encrypted file for all environments, which made dealing with different keys in development and production a little bit tricky. In Rails 6 this is finally solved with the support for per-environment credentials.
Table-less routes
If you ever feel lost in your application routes, you will definitely dig the new feature by my friend Benoit—rails routes --expanded
.
Say “no” to unwieldy tables!
$ rails routes -g direct_uploads --expanded
Prefix | rails_direct_uploads
Verb | POST
URI | /rails/active_storage/direct_uploads(.:format)
Controller#Action | active_storage/direct_uploads#create
Active Job tweaks
A couple of fresh Active Job improvements have also caught my attention:
-
We are now able to add timezone metadata to jobs to execute them in the same timezone as they have been enqueued (by the way, did you know that the current locale is also preserved during the job execution?)
-
A new timestamp field,
enqueued_at
to indicate the time the job has been enqueued. That allows to measure (e.g., with Yabeda) a very important performance characteristic—how long did the job has been waiting in a queue before the execution started?
Actionable errors
I’d like to finish with the feature authored by another friend of mine: Genadi Samokovarov, the creator of web-console is willing to make Rails developers happier with his Actionable Errors API.
This feature allows to add buttons to the standard Rails exception page, so that ActiveRecord::PendingMigrationError
can be solved by running migrations right from the error page inside a browser.
Anyone now can enhance their custom exceptions with actions, too!
As you see, a lot of things that I find exciting about Rails 6 come not as big announcements, but as sometimes obscure pull requests that can make a real difference for your production Rails application: either by implementing a very specific feature you have been waiting for, or by allowing you to go the extra mile with the features you already know and love.
In my opinion, the upcoming Rails upgrade is especially interesting for mature projects. To tell the truth, I never found a reason to upgrade a large Rails 4 codebase to Rails 5 when it was out. With Rails 6, migrating finally feels right.