Build Case-Insensitive Lookups with Rails' New QueryPredicates Types
Note: ActiveRecord::Type::QueryPredicates is coming in Rails 8.2. It’s merged to main (PR #58479) but not in a released gem yet. The latest release is 8.1.3, so the API can still shift before it ships. To try it, point your Gemfile at the main branch:
gem "rails", github: "rails/rails", branch: "main"
That pulls in all of main’s unreleased changes, not just this one. Don’t do it on an app you can’t afford to break.
Every app I’ve worked on has the same bug in five places. A user signs up as [email protected], then tries to log in as [email protected], and User.find_by(email:) comes back empty. So someone writes where("lower(email) = ?", email.downcase). Then someone else adds a uniqueness validation and forgets case_sensitive: false. Then a third person sorts the admin list by username and Zed lands above adal.
The rule “emails compare case-insensitively” belongs to the attribute, but until now Rails had nowhere to put it. Rails 8.2 adds one: an Active Record type can define the SQL expression that queries compare through. Write lower() once, in the type, and every find_by, where, order, and uniqueness check picks it up.
What We’re Building
A User model where:
emailandusernamematch regardless of case, so[email protected]finds[email protected].display_namealso ignores accents, so a search forjose martifindsJosé Martí.- The stored value keeps the user’s own casing.
- Every lookup uses a real index.
All with zero lower() calls in application queries.
The Rails Feature That Makes This Clean
Include ActiveRecord::Type::QueryPredicates in a type and override one method:
def comparison_expression(expression)
Arel::Nodes::NamedFunction.new("lower", [expression])
end
Active Record applies that expression symmetrically to both sides of every comparison: the stored column and the value you query with. That covers =, IN, !=, ranges, IS NULL, subqueries, and ORDER BY. Writes are untouched, because serialize runs as normal and the expression only applies when comparing. group and aggregates also use the stored value.
Building It
This build uses PostgreSQL. The case-insensitive half works on any adapter that has lower(). Note that SQLite’s lower() only folds ASCII letters.
The Migration
The type tells Rails how to compare. The database needs a matching expression index, both for speed and to enforce uniqueness at the database level:
class CreateUsers < ActiveRecord::Migration[8.2]
def change
create_table :users do |t|
t.string :email, null: false
t.string :username, null: false
t.string :display_name
t.timestamps
end
add_index :users, "lower(email)", unique: true, name: "index_users_on_lower_email"
add_index :users, "lower(username)", unique: true, name: "index_users_on_lower_username"
end
end
The index expression must be exactly the one the type emits, lower(email), or PostgreSQL won’t use it.
The Type
# app/types/case_insensitive_string.rb
class CaseInsensitiveString < ActiveRecord::Type::String
include ActiveRecord::Type::QueryPredicates
def comparison_expression(expression)
Arel::Nodes::NamedFunction.new("lower", [expression])
end
end
app/types autoloads like any other directory under app/. Restart the server after you create it.
The Model
class User < ApplicationRecord
attribute :email, CaseInsensitiveString.new
attribute :username, CaseInsensitiveString.new
normalizes :email, with: ->(email) { email.strip }
validates :email, :username, presence: true, uniqueness: true
end
That’s the whole feature. Notice what’s missing: no case_sensitive: false, no downcase in normalizes, no custom scopes. normalizes also composes with the type: Rails strips the query value first, then wraps it in lower().
Seeing It Work
User.create!(email: "[email protected]", username: "AdaL", display_name: "Ada Lovelace")
User.create!(email: "[email protected]", username: "josé", display_name: "José Martí")
User.create!(email: "[email protected]", username: "grace", display_name: "Grace Hopper")
User.create!(email: "[email protected]", username: "Zed", display_name: "Zoë Zed")
User.create!(email: "[email protected]", username: "bob", display_name: "Bob")
Lookups ignore case, and normalizes trims the input as well:
User.find_by(email: " [email protected] ").username
# SELECT "users".* FROM "users" WHERE lower("users"."email") = lower($1) LIMIT $2
# [["email", "[email protected]"], ["LIMIT", 1]]
# => "AdaL"
Arrays, negation, and ranges all use the same expression:
User.where(username: ["ADAL", "GRACE"]).pluck(:username)
# WHERE lower("users"."username") IN (lower($1), lower($2))
# => ["AdaL", "grace"]
User.where.not(username: "BOB").count
# WHERE lower("users"."username") != lower($1)
# => 4
User.where(username: "a".."c").pluck(:username)
# WHERE lower("users"."username") BETWEEN lower($1) AND lower($2)
# => ["AdaL", "bob"]
Ordering is case-insensitive, so Zed no longer jumps the queue:
User.order(:username).pluck(:username)
# ORDER BY lower("users"."username") ASC
# => ["AdaL", "bob", "grace", "josé", "Zed"]
The uniqueness validation uses the expression too:
user = User.new(email: "[email protected]", username: "ADAL")
user.valid?
# SELECT "users"."id" FROM "users" WHERE lower("users"."email") = lower($1) LIMIT $2
# SELECT "users"."id" FROM "users" WHERE lower("users"."username") = lower($1) LIMIT $2
# => false
user.errors.full_messages
# => ["Email has already been taken", "Username has already been taken"]
The stored value is untouched:
User.find_by(username: "adal").email
# => "[email protected]"
And the index is used:
User.where(email: "[email protected]").explain
Index Scan using index_users_on_lower_email on users
Index Cond: (lower((email)::text) = '[email protected]'::text)
find_or_create_by!, exists?, and first_or_create! go through the same predicate builder, so they match case-insensitively as well. The validation can still race with a concurrent insert. If that happens, the unique index raises ActiveRecord::RecordNotUnique, the same as for any other uniqueness check.
Taking It Further: Accent-Insensitive Names
Case is only half of the problem for names. A user who types jose marti expects to find José Martí. PostgreSQL’s unaccent extension removes accents, but you can’t index it directly, because it isn’t marked IMMUTABLE. The standard fix is a thin wrapper that pins the dictionary:
class AddFoldedDisplayNameIndex < ActiveRecord::Migration[8.2]
def up
enable_extension "unaccent"
execute <<~SQL
CREATE FUNCTION immutable_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
AS $$ SELECT public.unaccent('public.unaccent'::regdictionary, $1) $$;
SQL
add_index :users, "lower(immutable_unaccent(display_name))",
name: "index_users_on_folded_display_name"
end
def down
remove_index :users, name: "index_users_on_folded_display_name"
execute "DROP FUNCTION immutable_unaccent(text)"
end
end
schema.rb can’t record a custom function. Switch to config.active_record.schema_format = :sql so db/structure.sql keeps it. If you don’t, db:schema:load fails when it tries to build the index.
The type builds on the one we already have. Wrap the expression, then hand it to super:
# app/types/folded_string.rb
class FoldedString < CaseInsensitiveString
def comparison_expression(expression)
super(Arel::Nodes::NamedFunction.new("immutable_unaccent", [expression]))
end
end
class User < ApplicationRecord
attribute :display_name, FoldedString.new
# ...
end
User.where(display_name: "jose marti").pluck(:display_name)
# WHERE lower(immutable_unaccent("users"."display_name")) = lower(immutable_unaccent($1))
# => ["José Martí"]
User.find_by(display_name: "ZOE ZED").username
# => "Zed"
EXPLAIN shows a bitmap index scan on index_users_on_folded_display_name. The same pattern works for any comparison rule you can write as SQL: date(created_at) for day-level matching, or trim() for legacy data with stray whitespace.
Wrapping Up
We built a User model whose email, username, and display name compare the way people expect: case-insensitive, accent-insensitive, indexed, and enforced by the uniqueness validation. Each rule lives in one small type instead of in every query. The feature that made this possible is ActiveRecord::Type::QueryPredicates and its one method, comparison_expression.
For the implementation and its many edge cases (enums, serialized columns, subqueries, and association joins), read PR #58479. For more about custom attribute types, see type-safe JSON attributes with has_json.