I gave a talk at Rails World 2023 in Amsterdam on Powerful Rails Features You Might Not Know.
If all you've got is a hammer, everything looks like a nail. In tech, there is a constant stream of new features being added every day. Keeping up with the latest Ruby on Rails functionality can help you and your team be far more productive than you might normally be.
In this talk, we walk through a bunch of lesser known or easy to miss features in Ruby on Rails that you can use to improve your skills.
4. ActiveRecord Strict Loading
class Project < ApplicationRecord
has_many :comments, strict_loading: true
end
project = Project.first
project.comments
ActiveRecord::StrictLoadingViolationError
`Project` is marked as strict_loading. The Comment association named
`:comments` cannot be lazily loaded.
5. ActiveRecord Strict Loading
project = Project.includes(:comments).first
Project Load (0.3ms) SELECT "projects".* FROM "projects" ORDER BY
"projects"."id" ASC LIMIT ? [["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE
"comments"."project_id" = ? [["project_id", 1]]
=> #<Project:0x00000001054af780 id: 1>
project.comments
=> [#<Comment:0x00000001054aed80 id: 1, project_id: 1, body: "Hello
RailsWorld">]
6. Generated Columns
class AddNameVirtualColumnToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :full_name, :virtual,
type: :string,
as: "first_name || ' ' || last_name",
stored: true
end
end
8. with_options
class Account < ActiveRecord::Base
with_options dependent: :destroy do
has_many :customers
has_many :products
has_many :invoices
has_many :expenses
end
end
36. truncate_words
content = 'And they found that many people were sleeping better.'
content.truncate_words(5, omission: '... (continued)')
# => "And they found that many... (continued)"
38. Time.current.all_day
#=> Fri, 06 Oct 2023 00:00:00 UTC +00:00..
Fri, 06 Oct 2023 23:59:59 UTC +00:00
Time.current.all_week
#=> Mon, 02 Oct 2023 00:00:00 UTC +00:00..
Sun, 08 Oct 2023 23:59:59 UTC +00:00
Time.current.all_month
#=> Sun, 01 Oct 2023 00:00:00 UTC +00:00..
Tue, 31 Oct 2023 23:59:59 UTC +00:00
Time Helpers
53. authenticate_by
class User < ApplicationRecord
has_secure_password
end
User.find_by(email: "chris@gorails.com")
&.authenticate(“railsworld2023")
#=> false or user
User.authenticate_by(
email: "chris@gorails.com",
password: "railsworld2023"
) #=> user or nil
56. generates_token_for
class User < ApplicationRecord
generates_token_for :password_reset, expires_in: 15.minutes do
# BCrypt salt changes when password is updated
BCrypt::Password.new(password_digest).salt[-10..]
end
end
59. Named variants
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumbnail,
resize_to_limit: [200, 200]
end
end
60. Preprocessed variants
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumbnail,
resize_to_limit: [200, 200],
preprocessed: true
end
end
When all you’ve got is a hammer, everything looks like a nail.
You have a task and end up in the weeds to figure out a solution. Next time you come across a similar problem, you jump straight into the weeds remembering your solution from last time even though there may be better tools available to you this time around.
So the goal of this talk is a reminder to learn and master the tools you have available.
Let’s start off with a simple example.
Say you’re sending notifications for a conversation thread. Something like a GitHub issue.
Rails 7 added the excluding method to ActiveRecord.
By using excluding, we can think about the problem at a higher level instead of thinking on a SQL level.
Another example of this is strict loading. This was introduced in Rails 6.1 and helps prevent N+1 issues without any external dependencies.
ActiveRecord queries will now raise a Strict Loading error when you query an association that’s marked as strict_loading.
To do this properly, we can grab the project but eager load comments using includes(:comments).
This will load the project and the comments into memory
And when we ask for the project comments, we get the list of comments with no error this time.
Generated columns allow you to create a column computed from other columns in your database.
For example, we can make a “full name” column that is a combination of first and last name. Or calculate price_in_cents from a decimal price (or vice versa).
By using the “stored”, the column is computed when it’s written and is saved in storage like a normal column. When stored: false, it’s a virtual column that’s computed when read.
Generated columns were added to the PostgreSQL adapter in Rails 7 and has been supported in MySQL for a while and SQLite support is coming.
attr_readonly is another handy feature in ActiveRecord. We can still create a new user with super_admin set to true or false, but after that ActiveRecord cannot change the super_admin attribute.
Even update_column which skips validations and callbacks still verifies the readonly attributes.
with_options is a feature of ActiveSupport that lets you define options you want to include in every method call in the block.
Here we have 4 associations and all of them need dependent: :destroy, so we can use with_options to add that for every association.
You can also use with_options like this where we call it on the I18n class.
The i18n translate calls here get the locale and scope applied automatically.
The safe navigation operator in Ruby is amazing, but it doesn’t help in every situation.
Let’s say you want to call a method if an object responds to it.
This gets more complicated when we want to have a fallback.
Try lets us do this cleanly and it reads extremely well.
Another undervalued feature of Rails is ActionText. Sure, it format text, but the real magic is in embedding records from your database.
Here, you can see ActionText with a User record mentioned in it. When my name or avatar changes, the text renders with the new content.
You could also use this to embed YouTube videos, Twitter and Instagram posts.
The first step for embeds is asking the server for the embed data. The server will need to return a Signed GlobalID (SGID) and HTML preview for each user suggested.
A signed global ID looks like this. It’s a base64 encoded string with a signature at the end after the double hyphens. We’ll explore this more in a second.
When the user selects one of these entries, we can take the SGID and HTML preview on the client side and insert it into the Trix editor.
If I select myself here…
The JavaScript creates a new Trix Attachment using the HTML content and SGID and then inserts it into the editor.
Rails saves this to the database and it looks like this. We have an action-text-attachment element, but you’ll notice that it’s an empty tag. The HTML content is missing.
That’s because every time Rails renders this, it will look up the attachable (in this case, a User) and re-render the HTML content. This is what gets you the latest avatar and name every time it’s rendered.
When rendering, Rails looks at that Signed Global ID and finds the associated record.
Since these are Base64 encoded, we can decode them and you’ll see it’s a simple JSON message. It contains an expiration and purpose.
The message itself is also Base64 encoded.
If we decode the message, we’ll see that it is a regular Global ID that references a User model with the ID of 1 in our Jumpstart Pro app.
These signed Global IDs can also be extremely handy for building a polymorphic select box that allows you to choose a record from any model.
So here’s what our final result looks like. When we render our ActionText body, it finds each attachment and renders the partial for each associated record.
When my name or avatar changes, the next render will include the latest data.
A cool trick that ActionText uses internally is serializing with a custom coder.
Normally, we use Serialize to convert a Ruby hash to and from JSON or YAML. But you can use this to load and dump any object like ActionText does here.
To build a custom coder, all you need are the load and dump class methods. Here’s a simplified version of what ActionText does.
It simply creates a new Content object with the HTML and dumps the HTML back to the database while handling nil appropriately.
ActionMailbox is another favorite feature of mine that doesn’t get enough love.
Have you ever noticed the “via email” on GitHub issues and PRs?
You can implement something like this in Rails using ActionMailbox.
To do this, you’ll need to wire up ActionMailbox to your email provider. They will convert incoming emails to webhooks that are sent to your Rails application.
Inside ApplicationMailbox, you’ll define routings that match against the sender’s email address. Here, we’re taking anything @replies.domain.com and sending it to the replies inbox.
The replies inbox implements a process method that takes the email and creates a new post for the conversation using the sender’s email address and body of the email.
We can look up the right conversation by embedding the ID in the email address and parsing it out with a little regex.
Routing constraints let you define routes that are available depending on details in the request.
For example, this constraint says to send the root route to the dashboard controller if the user is on the app subdomain. Otherwise, we’ll use the marketing site homepage for requests with say www or no subdomain.
Routing constraints have access to the request and the session so you can do some cool things with it.
Devise provides some custom routing helpers too. Authenticated is a helper method that checks if the user is authenticated and then draws routes accordingly.
If you’re logged in and have the admin flag, you can access /admin
If you’re logged in, your root path will be the dashboard.
And if you’re not logged in, we’ll show you the homepage.
Fun fact, 11 years ago I contributed part of this feature to Devise and was one of my first open source contributions.
Another handy routing feature is “draw”.
Draw lets you organize routes into separate files.
If we say “draw :api”, the config/routes/api.rb file will be evaluated.
This is super handy to organize API or Admin routes separately. Or if you’re building a marketplace that has a separate storefront and backend areas.
Rails also lets you build your own generators.
In Jumpstart Pro applications, we provide an API client generator. Instead of adding another dependency, you can generate an API client by running rails g api_client and give it a name.
Of course, Rails provides a generator for generating generators so you can run “rails generate generator name” and it will generate a generator.
Templates get evaluated with Erb, so you can use Ruby to generate a Ruby file dynamically just like how the Scaffold generator creates controllers, models, and views.
On the fronted, we can customize Turbo by adding our own Turbo Stream Actions.
Let’s say you want to send a browser notification from Rails.
Turbo Streams are a simple HTML element that have an action attribute that maps to a JavaScript function on the client side. The server just needs to tell it which action to perform and provide any other attributes or HTML to render.
We can use the turbo_stream_action_tag helper to generate this HTML element. This can be in a response from an HTTP request or broadcasted through Redis from a background job.
Once the element is inserted into the body of the page, the JavaScript finds the matching Turbo StreamAction and executes it.
To send our notification, we can ask the browser if we have permission to send a notification and, if granted, we can create a new notification with the title from the attribute.
And then you’ll see a fancy notification in your browser.
There are countless methods in ActiveSupport, but truncating text is another common thing we need.
I learned about “truncate” years ago and have used it ever since, but truncate works by character count. The problem is this will often stop in the middle of a word and isn’t as nice as a user.
Only recently did I learn that truncate words exists. We can specify how many words we want to display and even customize the “omission” parameter to say “dot dot dot continued”.
It always takes me a second to process time comparisons when I read them. starts_at is less than Time.current, so that means we’re asking if starts at before the current time.
Rails 6 added before? And after? Helpers that alias less and greater than for making time comparisons easier to read at a glance.
You can also use past? And future? To make this even quicker to read at a glance.
ActiveSupport also provides time helpers for generating ranges.
all_day will take the given date and return a range from the beginning of the day to the end of the day.
all_week does the same but for the week the date is in. It also can be configured for weeks to start on Sunday or Monday. This example shows weeks starting on Mondays.
all_month does the same but for the month
Matt Swanson posted this on Twitter a little while back and I thought his solution was another great example of taking advantage of the tools Rails gives you.
In the case of small numbers, we want to display them with commas.
For numbers larger than ten thousand, we want to abbreviate them to the nearest scale like thousands, millions, billions.
In the past, I would jump right into implementing this from scratch, but as we’ve learned…
Rails ships with the number_to_human helper. It takes a number and outputs the number with the human scale at the end.
The actual implementation is in ActiveSupport, but exposed as an ActionView Helper to use in your Rails views.
This is almost what we want, but we need to figure out how to abbreviate this.
Precision lets us control how rounding works and significant: false tells it to use the precision number for fractional digits instead of significant digits.
So here’s how we can implement abbreviated numbers using the tools Rails gives us
Return the number with delimiter if it’s less than ten thousand
For larger numbers, we want number to human version with some configuration
If we set precision to 1 and significant: false, we get 1 number after the decimal place.
We want to round down so we don’t accidentally inflate our numbers
Setting the format allows us to remove the default space between the number and the unit
And last but not least, we can just replace the long unit names with abbreviations like K, M, and B.
All that without any custom logic, rounding or anything!
So here’s how we can implement abbreviated numbers using the tools Rails gives us
Return the number with delimiter if it’s less than ten thousand
For larger numbers, we want number to human version with some configuration
If we set precision to 1 and significant: false, we get 1 number after the decimal place.
We want to round down so we don’t accidentally inflate our numbers
Setting the format allows us to remove the default space between the number and the unit
And last but not least, we can just replace the long unit names with abbreviations like K, M, and B.
All that without any custom logic, rounding or anything!
Rails 7.1 also adds strict locals. If you add this magic comment to the top of your templates, we can require the “message” local to be passed in a s a local.
Default values are also supported, so here we can set “Hello” as our default message.
You can also define a partial that takes no locals by using empty parentheses.
Normalizes is another feature I’m excited about. When users input something like an email address, we need to clean it up by removing whitespace and downcasing it.
The best way to do this until now has been overriding the setter method so that it’s normalized as soon as the value is assigned. We also have to safely handle nil values and it has always felt a bit messy.
In Rails 7.1, we can replace that with a call to normalizes. By default, nil values will be skipped so we can simply call strip and downcase without any safe navigation.
This reads a little redundant so we can also use Ruby’s numbered parameters to simplify this using _1.
Authenticate_by allows you to authenticate a user while preventing timing attacks. It will query the database for the User and load it into memory if it exists. If it doesn’t, your HTTP request may return measurably faster allowing attackers to figure out if you have an account with this email or not.
Authenticate_by mitigates this by instantiating a User in memory if one is not found so the timing in either case is virtually the same.
Password_challenge has been added as well. This is useful for verifying the current password before changing it to a new password.
with_defaults is another ActiveSupport goodie that is an alias for reverse_merge that lets you set default values in a hash.
This makes sure that password_challenge is always set to a value, even if a crafty user tries submitting the form without a password_challenge.
generates_token_for lets you generate tokens that don’t need to be stored in your database. These are signed using ActiveSupport MessageVerifier and can include an expiration and embedded data.
If you embed data using a block, this will be matched when the token is used and allows you to make one-time use tokens. If we successfully update our password, the salt changes and attempting to use the token again will fail because the salts no longer match.
To generate a token, we get the “generate_token_for” method which takes the token name.
To use the token, we can call find_by_token_for and give it the name and token value.
I’ve you’ve used ActiveStorage, you’ve probably added variant code all over your views and/or helpers.
Rails 7.0 introduced named variants which allow you to replace these resize and other calls with a name (just like you would do with scopes!)
Rails 7.1 introduces preprocessed variants. If we slap proprocessed: true on our named variant, a TransformJob will run automatically on after_create_commit