What changed in Rails this year?
Buy my course: Security for Rails Developers.
2024 was an amazing year for the Rails community and I’d like to share a summary of what changed in the framework. As a TLDR, there were more than 4000 commits from 562 contributors and 55 releases, including Rails 8!
Let’s start with the documentation changes. The Rails Guides received a face-lift and the following guides got an update yhis year:
- Action Mailer Guides - PR
- Rails Error Reporting Guides - PR
- Action View Overview - PR
- Action View Helpers - PR
- Action View Form Helpers Guide - PR
- Active Record Migrations Guide - PR
- Active Record Associations - PR -Active Record Validations - PR
- Rails Routing - PR
- Action Controller - PR This same pull request created a new Advanced Topics Guides as well.
A new Rails Guide called Tuning Performance for Deployment(PR) was also added. This guide explains major concurrency and performance principles for Puma and CRuby.
Besides the new look, the Guides also received accessibility improvements.
This year, the Solid Trifecta was introduced and it is possible now to run a
Rails app with background jobs, caching and websockets, without Redis as a dependency.
Solid Cable was added as the default Action Cable adapter in production, Solid Queue, a database-based queuing backend for Active Job became the default job processor, and Solid Cache and Active Record backed caching backend was added as the default cache store.
A long awaited authentication generator was also added to Rails this year. It started out as pretty basic, but a bunch of improvements been made to it. For instance authentication of Action Cable connections and a password reset flow, that builds on top of the newly added default reset password token of has_secure_password
.
The development of Rails 8.0 started in May and it was released in November! A new demo from DHH can be seen on YouTube showcasing its usage. The video covers getting started with Rails 8 by building a basic blog, adding a WYSIWYG editor, putting it behind authentication, making it available as PWA, and deploying to production. In just 30 minutes!
Let’s move on to the removals and deprecations from this year. Rails UJS was deprecated
since Rails 7, and it has been completely
wiped from the Rails codebase this
year. Also, all code deprecated in 7.1 was removed from the codebase.
And there were new deprecations:
-
The
active_job.enqueue_after_transaction_commit
setting has been deprecated. This behavior is not intended to be changed globally, but on a per-job basis. - Multiple path route mapping was deprecated due to improving performance and you may use
with_options
or a loop to make drawing multiple paths easier.# Before get "/users", "/other_path", to: "users#index" # After get "/users", to: "users#index" get "/other_path", to: "users#index"
- Ruby plans to make benchmark a bundled gem, and Rails deprecated
the
Benchamark.ms
core extension in favor of the bundled gem in the future. -
Passing
nil
as model argument toform_with
was also deprecated this year. -
The
unsigned_float
andunsigned_decimal
short-hand column methods were deprecated. As of MySQL 8.0.17, theUNSIGNED
attribute is deprecated for columns of typeFLOAT
,DOUBLE
, andDECIMAL
. Consider using a simple CHECK constraint instead for such columns. More details can be found here. -
ActiveSupport::ProxyObject was also deprecated in favor of Ruby’s built-in BasicObject.
-
ActiveRecord::Base.connection
andActiveRecord::ConnectionAdapters::ConnectionPool#connection
was deprecated in favor oflease_connection
. The method has been renamed aslease_connection
to better reflect that the returned connection will be held for the duration of the request or job.ActiveRecord::Base.connection
’s deprecation is a soft deprecation, no warnings will be issued and there is no current plan to remove the method. -
Now that Hotwire is the default in Rails, the
channels
folder was dropped from defaultapp/
structure. The folder still gets created when using thechannel
generator if needed. - The rarely used default
permissions_policy
configuration files was also dropped. The configuration can be added back as needed referring to the documentation ofpermissions_policy
instead.
A lot has been removed and deprecated but a lot of new things has been added to Rails! There was focus on making building Progressive web-apps(PWAs) a breeze with Rails. Starting by adding default PWA manifest and service worker file. Freshly generated Rails apps now include a manifest and service worker file to become full-fledged Progressive Web Applications.
The asset pipeline defaults to Propshaft in Rails 8.
Moving on from Sprockets to Propshaft in Rails 8, the --asset-pipeline
flag will no longer take an argument for “propshaft” or “sprockets”, when generating a new Rails application.
Brakeman was also added to newly
generated apps by default for enhanced security by doing static code analyses.
A default GitHub CI file was also added to run Brakeman and Rubocop and these files are also getting generated for plugins and can be skipped by the --skip-rubocop
and --skip-ci
flags.
To control which browsers can access your application, the allow_browser helper was added to Rails.
Another controller related addition is the built-in rate-limiter. The API for rate limiting built into Action Controller which was depending on the Kredis limiter type at the beginning, but it was quickly refactored to use the Rails cache store.
Here is an example of usage:
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create
end
class SignupsController < ApplicationController
rate_limit to: 1000, within: 10.seconds,
by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups!" }, only: :new
end
The rate limiter has the ability to use multiple rate limits per controller
Parameter filtering capability was also added for redirect locations. This feature utilizes the config.filter_parameters to determine which parameters should be filtered. As a result, redirects will not display filtered parameters, ensuring sensitive information remains protected. A redirect location with filtered parameters will now look like: Redirected to secret.foo.bar?username=roque&password=[FILTERED].
Parameters#expect
is a new way to handle params giving more control over what you expect to receive in your controller actions.
# Before
params.require(:table).permit(:attr)
# After
params.expect(table: [ :attr ])
After an extensive discussion about setting a new default for the Puma thread count, the default number of threads in the Puma config has now been updated from 5 to 3. Also, Rails now suggest puma-dev as the golden path for developing multiple Rails applications locally, if you’re not using Docker. bin/setup
has now been updated to suggest how to get that setup. Continuing on support for puma-dev “.test” was added as a default allowed host in development to ensure a smooth setup.
The Rails console is built on top of IRB, but due to the lack of an extension API, it was extending it with monkey patches. Since IRB now has a new extension API, the Rails console was rebuilt on top of that. This will make the Rails helpers show up in the help message among other improvements. Also, the Rails console now indicates the current Rails environment with the name and a color.
There were a lot of Active Record related changes to Rails this year.
Delegatable types has an introspection method in the form of <role>_types now.
The schema_dump, query_cache, replica & database_tasks became configurable via DATABASE\_URL
.
The row_count
field was added to the sql.active_record
notification, which returns the amount of rows returned by the query that emitted the notification. This metric is useful in cases where one wants to detect queries with big result sets.
ActiveRecord::Encryption::Encryptor
received a new option to disable compression:
class User
encrypts :name, encryptor: ActiveRecord::Encryption::Encryptor.new(compress: false)
end
You may want to avoid compression if your data is already compressed, or to
prevent revealing information about the entropy of the encrypted value.
A compressor option was also added to Active Record
encryption to customize the
compression algorithm used. The default compressor is Zlib (as it was before).
Also, encrypting binary columns is now
supported. Previously this incidentally worked for MySQL and SQLite, but not PostgreSQL.
The object returned by explain
now responds to pluck
, first
, last
, average
, count
, maximum
, minimum
, and sum
. Those new methods run EXPLAIN
on the corresponding queries, for instance:
User.all.explain.count
# EXPLAIN SELECT COUNT(*) FROM `users`
User.all.explain.maximum(:id)
# EXPLAIN SELECT MAX(`users`.`id`) FROM `users`
ActiveRecord::Transactions::ClassMethods#set_callback
was introduced. It behaves like ActiveSupport::Callbacks::ClassMethods#set_callback
with support for the :on
option available on #after_commit
and #after_rollback
callbacks. For example:
class User
set_callback :commit, :after, :do_some_work, on: :update
end
Talking about callbacks, ActiveRecord::Base.transaction
now yields an ActiveRecord::Transaction
object, which allows to register callbacks on it. For instance:
Article.transaction do |transaction|
article.update(published: true)
transaction.after_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
end
ActiveRecord::Base.current_transaction
was also added and it also allows to register callbacks on it:
Article.current_transaction.after_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
And last of the callback changes, ActiveRecord.after_all_transactions_commit
callback
was added. It is useful for code that may run either inside or outside a transaction and needs to perform work after the state changes have been properly persisted.
def publish_article(article)
article.update(published: true)
ActiveRecord.after_all_transactions_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
end
ActiveRecord::Relation#readonly? was also added to the relation object, and developers can check with it if the relation was marked readonly.
strict_loading_mode
now can be configured globally, via config.active_record.strict_loading_mode. It defaults to :all and it can be changed to :n_plus_one_only to only report when loading associations that will lead to an “N + 1 query”. This can be set globally or within a model.
A filter option was added to in_order_of to prioritize certain values in the sorting, without filtering the results by these values. The same change was done to Enumerable as well in.
Now it is possible to ignore counter cache columns while they are backfilling. To safely backfill a column, while keeping the column updated with child records added/removed, use:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: { active: false }
end
While the counter cache is not “active”, the methods like size/any? will not use it, but get the results directly from the database. After the counter cache column is backfilled, simply remove the { active: false } part from the counter cache definition.
Another counter cache related addition is that now it is possible to reset cache counters for multiple records without any extra queries.
ActiveRecord::Base#pluck now accepts hash values, the same applies to .pick
, which is implemented using .pluck
:
# Before
Post.joins(:comments).pluck("posts.id", "comments.id", "comments.body")
# After
Post.joins(:comments).pluck(posts: [:id], comments: [:id, :body])
When using Active Record Query Logs, the source_location
tag option can be used now to show where a query is defined:
Active Record batching can use custom columns now:
Product.in_batches(cursor: [:shop_id, :id]) do |relation|
# do something with relation
end
The SQLite3 support also received some love and full-text search and other
virtual tables are now supported in
Rails. Previously, adding SQLite3 virtual tables messed up schema.rb
, but with
this change, virtual tables can safely be added using create_virtual_table
.
Also, the SQLite adapter was
updated to use IMMEDIATE
mode
whenever possible in order to improve concurrency support and avoid busy
exceptions.
The sqlite3 gem v2.4.0 introduced support for loading extensions passed as a
kwarg to Database.new. Rails leverages that feature to allow configuration of
extensions in the
config/database.yml file using either filesystem paths or the names of modules
that respond to to_path method.
ActiveRecord::Base.with_connection
was added as a shortcut for leasing a connection for a short duration](https://github.com/rails/rails/pull/51083). The leased connection is yielded, and for the duration of the block, any call to ActiveRecord::Base.connection
will yield that same connection.This is useful to perform a few database operations without causing a connection to be leased for the entire duration of the request or job.
If your application uses sharding, the newly added .shard_keys, .sharded?, & .connected_to_all_shards methods can become handy.
class ShardedBase < ActiveRecord::Base
self.abstract_class = true
connects_to shards: {
shard_one: { writing: :shard_one },
shard_two: { writing: :shard_two }
}
end
class ShardedModel < ShardedBase
end
ShardedModel.shard_keys => [:shard_one, :shard_two]
ShardedModel.sharded? => true
ShardedBase.connected_to_all_shards { ShardedModel.current_shard } => [:shard_one, :shard_two]
A dirties
option were added toActiveRecord::Base.uncached
and ActiveRecord::ConnectionAdapters::ConnectionPool#uncached
. When set to true
(the default), writes will clear all query caches belonging to the current thread. When set to false
, writes to the affected connection pool will not clear any query cache.
This is needed by Solid Cache so that cache writes do not clear query caches.
While Rails 7.1 has added support for writing Common Table Expressions(CTEs), this support did not extend to recursive CTEs.
The QueryMethods#with_recursive
construct was added to enable recursive CTEs.
A Date decoder was added to the PostgreSQL adapter to type cast dates at the connection level, so when a raw query is run, the columns will be cast to a date instead of a string.
Before:
ActiveRecord::Base.connection.select_value("select '2024-01-01'::date").class
#=> String
After:
ActiveRecord::Base.connection.select_value("select '2024-01-01'::date").class
#=> Date
This change brings the PostgreSQL adapter to parity (for dates) with the Mysql2
adapter. Another PostgreSQL related change is to allow disable_extension
to
be called with schema-qualified name
for PostgreSQL. This adds parity with enable_extension
, the
disable_extension
method can be called with a schema-qualified name (e.g.
disable_extension "myschema.pgcrypto"
). Note that PostgreSQL’s DROP
EXTENSION
does not actually take a schema name (unlike CREATE EXTENSION
), so
the resulting SQL statement will only name the extension, e.g. DROP EXTENSION
IF EXISTS "pgcrypto"
.
A not-null modifier was added to the migration
generator, which we can now specify
using a !
after column types:
# Generating with...
bin/rails generate migration CreateUsers email_address:string!:uniq password_digest:string!
# Produces:
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end
And while we are at the migrations, support for dropping multiple tables at once was also added to Rails.
An except_on
option was added to
validations, it provides the ability to skip Active Model validations in specified contexts.
Now you can define enums without the extra brackets using keyword arguments:
# Before
enum :status, { default: 0, scopes: 1, prefix: 2, suffix: 3 }
# After
enum :status, default: 0, scopes: 1, prefix: 2, suffix: 3
Also, rename_enum
was made more consistent with rename_table
, and it accepts not a from and to positional arguments similar to renaming tables
The final Active Record related change I want to mention is the ENV[“SKIP_TEST_DATABASE_TRUNCATE”] flag was added to speed up multi-process test runs on large databases, when all tests run within default transaction.
To make testing easier, the assert_broadcasts now not only confirms the broadcast but also provides access to the messages that were broadcast. This enhancement, similar to what we have in assert_emails, facilitates additional analyses of the transmitted messages. Here’s an example:
def test_emails_more_thoroughly
email = assert_emails 1 do
ContactMailer.welcome.deliver_now
end
assert_email 'Hi there', email.subject
emails = assert_emails 2 do
ContactMailer.welcome.deliver_now
ContactMailer.welcome.deliver_later
end
assert_email 'Hi there', emails.first.subject
end
Also, now we can now use various test helper methods for notification assertions:
assert_notification("post.submitted", title: "Cool Post") do
post.submit(title: "Cool Post") # => emits matching notification
end
assert_notifications_count("post.submitted", 1) do
post.submit(title: "Cool Post")
end
assert_no_notifications("post.submitted") do
post.destroy
end
notifications = capture_notifications("post.submitted") do
post.submit(title: "Cool Post") # => emits matching notification
end
The “Rails::TestUnitReporter#prerecord” method was added to the Rails TestUnitReporter class, Minitest will pick it up and invoke it before invoking the test, allowing to print the test name in advance. This is useful to debug slow and stuck tests by turning on verbose mode. This way the stuck test name is printed before the process deadlocks.
A generic “fixture” method was exposed in tests to avoid conflicting methods. For example with Minitest, it is possible now to load fixtures like this:
assert_equal "Ruby on Rails", web_sites(:rubyonrails).name
assert_equal "Ruby on Rails", fixture(:web_sites, :rubyonrails).name
And a new assert_initializer method was added to compliment the existing initializer generator action.
I am sure you’ve seen an exception due to not having the default_url_options set in development and in the tests, but from now on, it has a default value. It is very easy to write a “false positive” broken test that actually tests nothing (or can become such in the future). A simple example:
def test_active
active_users = User.active.to_a
active_users.each do |user|
assert user.active?
end
end
The assertion is only run if the scope returns at least one user. There is a new option to have such tests reported in Active Support. With the following configuration, assertionless tests will be marked as failed and not silently pass.
config.active_support.assertionless_tests_behavior = :raise # also available :ignore and :log
A test class can now override the default use_transactional_tests
setting
for individual databases, which can be useful if some databases need their
current state to be accessible to an external process while tests are running.
class MostlyTransactionalTest < ActiveSupport::TestCase
self.use_transactional_tests = true
skip_transactional_tests_for_database :shared
end
The development and test environment tend to reload code and redefine methods (e.g. mocking), hence YJIT isn’t generally faster in these environments. For this reason, YJIT will be disabled by default from Rails 8.1 in these environments.
The save_and_open_page
capybara
helper now can be used from within
system tests. This helper lets developers inspect the status of the page at any
given point in their tests.
There were developer experience improvements too this year. For one, there is often a need to quickly see how many SQL queries the current action produced. For example, to quickly check if N+1 was solved or if the caching is working and so the number of queries reduced etc. This can be done manually by inspecting the logs and counting the number of queries, but you don’t need to do that anymore, because the queries count has been added to the template rendering instrumentation. This is how the output changed:
# Before
Completed 200 OK in 3804ms (Views: 41.0ms | ActiveRecord: 33.5ms | Allocations: 112788)
# After
Completed 200 OK in 3804ms (Views: 41.0ms | ActiveRecord: 33.5ms (2 queries, 1 cached) | Allocations: 112788)
An internal route at
rails/info/notes
was also added, to display the same information you would get
from running bin/rails notes
so we can check the notes on UI.
Highlighting of multi-line methods in ERB
templates was also improved this
year.
Also, when running kamal dbc
the --include-password
flag is passed now to reuse the database password from
database.yml, so you don’t need to enter the password.
This year, devcontainer support was also added to Rails. Starting by
generating a .devcontainer
folder
with everything needed to boot the app and do development in a remote
container. These files can be skipped using the
--skip-devcontainer
option. Then node and yarn was added to devcontainer when
creating a project with Javascript.
Later in the year devcontainers were change to opt in and a devcontainer
command was created So, new apps
will only get a devcontainer if you pass the --devcontainer
flag to rails
new
. Additionally, you will be able to generate a devcontainer for an existing
app with bin/rails devcontainer
. And Kamal also became supported in
devcontainers.
Thruster is an asset compression and caching proxy with X-Sendfile acceleration that speeds up simple production-ready deployments of Rails applications. It runs alongside the Puma and usually behind the Kamal 2 proxy, which offers HTTP/2 and SSL auto-certificates, to help your app run efficiently and safely on the open Internet. It has been added by default to Rails 8+ apps
Kamal was also added for deployment
by default, which includes generating a Rails-specific config/deploy.yml
. This
can be skipped using --skip-kamal
. Check out more about Kamal on its
official site.
Related to Kamal, a new Rails::Rack::SilenceRequest
middleware was added to silence
requests to “/up” via config.silence_healthcheck_path = path
. This prevents
the Kamal-required healthchecks from clogging up the production logs. The
middleware supports regex in path
filtering . For example:
config.middleware.insert_before Rails::Rack::Logger,
Rails::Rack::SilenceRequest, path: /up$/
will silence logs from paths ending in “up”.
Let’s move on to Active Job. A common mistake with Active Job is to enqueue jobs from inside a transaction, causing them to potentially be picked and ran by another process, before the transaction is committed, which result in various errors.
Topic.transaction do
topic = Topic.create
NewTopicNotificationJob.perform_later(topic)
end
Now Active Job will automatically defer the enqueuing to after the transaction is committed, and drop the job if the transaction is rolled back. Various queue implementations can choose to disable this behavior, and users can disable it, or force it on a per job basis:
class NewTopicNotificationJob < ApplicationJob
self.enqueue_after_transaction_commit = :never # or :always or :default
end
Support for accepting a block for ActiveJob::ConfiguredJob#perform_later
was also added and Sucker Punch was removed from the list of Active Job adapters.
Ruby’s use of malloc
can create memory fragmentation problems, especially when using multiple threads like Puma does. This is why Rails switches to jemalloc in the default Dockerfile. jemalloc uses different patterns to avoid fragmentation can decrease memory usage by a substantial margin.
Speaking of Docker, when running Rails applications with Docker containers may fail to restart if they crashed (e.g. because of OOM) because the /rails/tmp/pids/server.pid
file is already present. To avoid this, new apps now avoid generating this pid file.
ActiveModel::AttributeAssignment#attribute_writer_missing was introduced to provide instances with an opportunity to gracefully handle assigning to an unknown attribute:
class Rectangle
include ActiveModel::AttributeAssignment
attr_accessor :length, :width
def attribute_writer_missing(name, value)
Rails.logger.warn "Tried to assign to unknown attribute #{name}"
end
end
rectangle = Rectangle.new
rectangle.assign_attributes(height: 10)
# => Logs "Tried to assign to unknown attribute 'height'"
Another attributes related addition was the default: support for ActiveSupport::CurrentAttributes.attribute. This extends the attribute
class method to accept a :default
option for its list of attributes:
class Current < ActiveSupport::CurrentAttributes
attribute :counter, default: 0
end
A new script
default folder was also added this year. It is supposed ti hold one-off or general purpose
scripts, such as data migration scripts, cleanup scripts, etc.
The new script generator allows us to create such scripts:
rails generate script my_script
# We can also specify a folder, when generating scripts:
rails generate script cleanup/my_script
# We can then run the generated scripts using:
ruby script/my_script.rb
The new bin/rails boot
command boots the application and exits. Supports the standard -e/--environment
options. It can be handy when you want to test the boot logic of a Rails app or when benchmarking something.
Another command related change was to turn app:update into a command and then adding the --force
flag to it, to allow running bin/rails app:update
while accepting all the changes it makes.
The newly added 'wasm-unsafe-eval'
keyword for the Content Security Policy allows the loading and execution of WebAssembly modules without the need to allow unsafe JavaScript execution via 'unsafe-eval'
.
A new option was added
to the expires_in
method to support the immutable
directive for the “Cache-Control” header.
Moving onto the view layer, the check_box
helper methods got renamed
to checkbox
and kept the old names as aliases. The same change was done to text_area
in another pull request.
A new option was added to ActionText to allow configuring whether or not to store empty rich text fields.
It is the store_if_blank
option on has_rich_text
. It defaults to true
(the current behaviour); if you pass false
, ActionText won’t create ActionText::RichText
records when saving with a blank value.
The error pages built into Rails have been updated, here’s a preview of the new look:
A new maintenance policy was also introduced this year.
The main changes are:
- Releases are maintained by a pre-defined, fixed period of time. One year for bug fixes and two years for security fixes.
- Distinction between severe security issues and regular security issues is removed.
- Npm versioning is updated to match not use the pre-release - separator.
That’s it for the code changes. A lot has been done by the Rails Foundation too this year, Amanda wrote a recap on the Rails blog, it is definitely worth a reading!.
I am sure you are tired of reading by now, so it is time to say good bye! I wish a happy and prosperous new year!
Or follow me on Twitter
I run an indie startup providing vulnerability scanning for your Ruby on Rails app.
It is free to use at the moment, and I am grateful for any feedback about it.If you would like to give it a spin, you can do it here: Vulnerability Scanning for your Ruby on Rails app!