Rails 6: B-Sides and Rarities

Cover for Rails 6: B-Sides and Rarities

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 write user.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 */
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 Benoitrails 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.

Actionable Errors

Now you can run migrations by clicking a button in a browser

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.

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.