Introduction

Rails 7.1 didn’t ship one big flashy feature. It shipped a pile of small, practical ones — the kind that quietly delete boilerplate you’ve been writing by hand for years.

These are the ones I actually reach for.

normalizes: clean attributes without a callback

How many times have you written a before_save just to downcase an email? Rails 7.1 turns that into a declaration:

class User < ApplicationRecord
  normalizes :email, with: ->(email) { email.strip.downcase }
end

The normalization runs on assignment, so user.email = " Me@Example.com " stores me@example.com. Better still, it also applies to finders:

User.find_by(email: " Me@Example.com ")
# looks up "me@example.com"

No more remembering to normalize in three places. The rule lives in one.

generates_token_for: scoped, self-invalidating tokens

Password resets, email confirmations, unsubscribe links — they all need a signed token that’s tied to a purpose and expires. Rails 7.1 bakes that in:

class User < ApplicationRecord
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt&.last(10)
  end
end

token = user.generate_token_for(:password_reset)

# Later, in the controller:
User.find_by_token_for(:password_reset, token) # => the user, or nil

The clever part is the block. Whatever it returns is embedded in the token and checked on lookup, so when the user’s password changes their salt changes, and every outstanding reset token becomes invalid automatically. No extra column, no manual expiry bookkeeping.

authenticate_by: login without the timing leak

If you authenticate with has_secure_password, the obvious code has a subtle flaw:

user = User.find_by(email: params[:email])
user&.authenticate(params[:password])

When no user matches, find_by returns immediately and skips the password digest — so a login attempt for a non-existent account is measurably faster. That timing difference leaks which emails exist. authenticate_by closes it:

User.authenticate_by(email: params[:email], password: params[:password])
# => the user on success, nil otherwise — in constant time

It always does the digest work, matching or not, so you can’t tell a missing account from a wrong password by timing it.

Composite primary keys

Rails 7.1 supports composite primary keys as a first-class concept, derived from the schema. Join tables and models keyed on more than one column ([:tenant_id, :id]) no longer need a workaround gem — associations, find, and query building understand the composite key directly.

Async aggregates

For pages that fire several independent queries, 7.1 adds asynchronous aggregate methods: async_count, async_sum, async_average, async_minimum, async_maximum, async_pluck, async_pick, async_ids.

Each returns a promise you resolve later, so the queries run concurrently instead of one after another:

total = Order.async_count
revenue = Order.async_sum(:amount)

# both queries are already in flight
[total.value, revenue.value]

It only pays off with a database and adapter that support concurrent connections, but for a heavy dashboard it’s real wall-clock time saved.

A few smaller wins

  • perform_all_later enqueues many jobs in one call, using bulk-enqueue on adapters that support it instead of a round trip per job.
  • Dockerfiles by defaultrails new now generates a production Dockerfile, .dockerignore and entrypoint, so containerised deploys start from a sane baseline.
  • config.autoload_lib(ignore: %w[assets tasks]) finally makes lib a proper autoload path, without hand-rolling the configuration.

Wrapping up

None of this is glamorous, and that’s rather the point. Rails 7.1 is a maintenance-and-polish release: normalizes and generates_token_for alone delete a callback and a whole token model I used to write by hand, and authenticate_by fixes a security footgun most of us had in our login code without realising it. Unglamorous, but I use these every week.

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht