Top 10 Most Under-rated Rails Tricks Most People Don’t Use

Rails’ conventions become so instinctual in engineers as they develop that they oftentimes copy the same code and design patterns not realising that there are a whole bunch of ways to do things that are either more performant, or cleaner and easier to maintain.

If you look a bit harder you can uncover a world of “better” solutions to do many commonly needed functions.

ActiveRecord’s #missing and #associated

Did you know you can easily query models that have either have zero or at least one of the specified association?

# (6.1) Gets all posts that have no comments and no tags. 
Post.where.missing(:comments, :tags)

# (7.0) Gets all posts that have at least 1 comment
Post.where.associated(:comments)

ActiveRecord greater and less than using infinity range

As long as you are using Rails 5.0+ and Ruby 2.6, you can use the (infinity) range object for less than and greater than in an ActiveRecord relation?

# (5.0) Returns all users created in the last day.
User.where(created_at: 1.day.ago..)

# (5.0) Returns all users with less than 10 login attempts.
User.where(login_attempts: ..10)

ActionPack Variant to dynamically render different layouts

This one blew my mind!

Sometimes you want to use a different view layout, for example regular users use one layout, and admins use another. Request variants do exactly that!

# (4.1) ActionPack variants
class DashboardController < ApplicationController
def show
request.variant = current_user.admin? ? :admin : :regular
end
end

# If admin, uses: app/view/dashboards/show.html+admin.erb
# If not, uses: app/view/dashboards/show.html+regular.erb

Using #scoped and #none

Sometimes you need to either return an ActiveRecord relation object that represents all the records of a model, or perhaps no records at all. This can be done with the #scoped and #none. Historically, ‘none’ has been simulated by returning an empty array, but using an array causes problems, and means that you cannot guarantee that the returned value (for example with the sample below) will respond to the same method signatures that the other pathways will do. This is just better object-oriented design.

def search(query)
case query
when :all
scoped
when :published
where(:published => true)
when :unpublished
where(:published => false)
else
none
end
end

Why is my query slow? Use #to_sqland #explain

Sometimes ActiveRecord relations do not always act the way you expect them to, or sometimes, you need to verify that the database queries are using the correct indices. Check that your hard-fought struggles with your ActiveRecord relations are generating the SQL (and database behaviour you envision).

# Output the SQL the relation will generate.
Post.joins(:comments).to_sql

# Output the database explain text for the query.
Post.joins(:comments).explain

Filtering ActiveRecord results with merge

I really cannot believe that this isn’t covered (or at least if it is, I haven’t seen it) in any of the default documentation, nor in any book or guide I’ve. It is completely bewildering since its an incredibly common usage pattern and hardly anyone knows about it. It lets you join onto a named scope, filtered by the result of that named scope.

class Post < ApplicationRecord
  # ...

  # Returns all the posts that have unread comments.
  def self.with_unread_comments
    joins(:comments).merge(Comment.unread)
  end
end

Multiple variable assignment using the splat * operator

One thing that everyone should know about is using the splat operator on objects other than arrays.

match, text, number = *"Something 981".match(/([A-z]*) ([0-9]*)/)

# match = "Something 981"
# text = "Something"
# number = 981

other examples include:

a, b, c = *('A'..'Z')

Job = Struct.new(:name, :occupation)
tom = Job.new("Tom", "Developer")
name, occupation = *tom

(thanks to slack-overflow community wiki for this one)

Asynchronous Querying

Rails 7.0 introduced #load_async that loads ActiveRecord relations (queries) in background threads. This can dramatically improve performance which you need to load several unrelated queries in the controller.

def PostsController
  def index
    @posts = Post.load_async
    @categories = Category.load_async
  end
end

In Rails 6.0 (or less) the queries above took 200ms each, the controller would take 400ms to execute these serially. With #load_async in Rails 7 the equivalent code would only take as long as the longest query!

Stream Generated Files from Controller Actions

send_stream in Rails 7.0 lets you stream data to the client from the controller for data being generated on the fly. Previously this had to be buffered (or stored in a tempfile) and then used send_data to transmit the data. This will be awesome for SSE and long-polling options in Rails.

send_stream(filename: "subscribers.csv") do |stream|
  stream.write "email_address,updated_at\n"
 
  @subscribers.find_each do |subscriber|
    stream.write "#{subscriber.email_address},#{subscriber.updated_at}\n"
  end
end

find_each is an oldie but a goodie that is massively under-used!

Finally, stop using #each to iterate over large numbers of records. When you use #each Active Record runs the entire query, instantiates ALL the objects needed for the query and populates their attributes from the result set. If the query returns a LOT of data, this process is slow and more importantly, uses a tonne of memory. Instead when you know there are going to be 100s or 1000s of results, use #find_each to only load the object in batches of (by default) 1000 records (you can change this on each usage). Here is an example:

Book.where(:published => true).find_each do |book|
puts "Do something with #{book.title} here!"
end