How to store username or email with case insensitive search using Ecto
I am building a small personal project which stores users in a users table and
every user has a unique email. So, my first model looked something like below:
create index(:users, [:email], unique:true) create index(:users, [:magic_token], unique:true) create index(:users, [:confirmation_token], unique:true) end end
defmoduleSF.Userdo use Ecto.Schema import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate:true} @foreign_key_type:binary_id schema "users"do field :email, :string field :magic_token, Ecto.Base64UUID field :confirmation_token, Ecto.Base64UUID field :confirmed_at, :naive_datetime
timestamps() end
@docfalse defchangeset(user, attrs) do user |> cast(attrs, [:email, :confirmation_token]) |> validate_required([:email]) |> unique_constraint(:email) end end
Like all good developers I had a unique index on the email field to make the
searches faster. So, when I do a Repo.get_by(User, email: "danny@m.com"),
postgres doesn’t have to scan the whole table to find my user. However, users
sometimes enter email in mixed case, so some people might enter the above email
as `DANNY@m.com`, and since postgres makes a distinction between upper cased and
lower cased strings, we would end up returning a 404 Not found error to the
user. To work around this I would usually lower case the email whenever it
entered the system, in Rails you would do something like below:
classCreateUsers < ActiveRecord::Migration[5.2] defchange create_table :users, id::uuiddo|t| # ... end add_index :users, %i[email], unique:true end end
classUser < ActiveRecord::Base # downcase email before saving before_save :normalize_email
defnormalize_email self.email = email&.downcase end
# always downcase before you find a record deffind_by_email find_by(email: email.downcase) end end
One downside of this approach is the need to ensure that all the emails in the
database are stored as lower case. If you mess up on your data entry code, you
might end up with a table containing the same email with different cases.
A better way to do this in Ecto would be to create an index on a lower cased
email like so:
This way you would never end up with a table with duplicate emails, and when you
want to find a user with an email you can do something like below:
1 2 3 4 5 6 7 8 9 10 11 12 13
defmoduleSF.UserServicedo deffind_by_email(email) do email = String.downcase(email)
user = Repo.one( from u in User, where: fragment("lower(?)", u.email) == ^email )
if user != nil, do: {:ok, user}, else: {:error, :not_found} end end
This would also make sure that your index is actually used. You can take the SQL
logged in your IEx and run a quick EXPLAIN to make sure that your index is
properly being used:
1 2 3 4 5 6 7 8 9 10 11 12 13
# EXPLAIN ANALYZE SELECT u0."id", u0."email", u0."magic_token", u0."confirmation_token", u0."confirmed_at", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (lower(u0 ."email") ='foobar@x.com'); ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ QUERY PLAN │ ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Index Scan using users__lower_email_index on users u0 (cost=0.14..8.16rows=1 width=588) (actual time=0.013..0.014rows=0 loops=1) │ │ Index Cond: (lower((email)::text) ='foobar@x.com'::text) │ │ Planning time: 0.209 ms │ │ Execution time: 0.064 ms │ └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ (4rows)
Time: 1.086 ms
A common rookie mistake is creating an index on the email column and then comparing
in sql using the lower function like so: