A Quick Dip into Ecto Types

A question came up on the #ecto Slack channel this weekend asking if Ecto supports atoms as a field type. I didn't know the answer but wondered if it could be done with a custom type.

The tl;dr is that it can be done as you can see here: https://github.com/parkerl/curiousecto/blob/customatomectotype/lib/atomtypeproblem.ex

Let's quickly walk through the code. The Ecto.Type documentation clearly outlines how to accomplish creating a custom type http://hexdocs.pm/ecto/Ecto.Type.html.

We must create a module and include the type behaviour with @behaviour Ecto.Type. Next we need to implement 4 functions, type/0, cast/1, load/1, and dump/1.

The type/0 function tells Ecto what "real" database type should back the custom type. The cast/1 function allows us to take values and turn them into something that can be stored in the database. The load/1 function tells Ecto what to do with values coming out of the database and dump/1 handles values going in. The last three expect must return a tuple in the format {:ok, value} or an :error atom.

Here is how to implement the atom field type backed by a string field in the database.

  defmodule AtomType do
    @behaviour Ecto.Type
    def type, do: :string

    def cast(value), do: {:ok, value}

    def load(value), do: {:ok, String.to_atom(value)}

    def dump(value) when is_atom(value), do: {:ok, Atom.to_string(value)}

    def dump(_), do: :error
  end

You can see the database type is set to :string. We don't need any fancy casting in this case so we just return the value inside an :ok tuple. The heavy lifting is all done by dump/1 and load/1. On the way into the database we convert an atom to a string. If an atom is not given we return :error. On the way out, we reverse the process in load/1.

Now we can employ our new AtomType in the model.

  schema "typed_table" do
    field :atom_type, Curious.TypedTable.AtomType
    timestamps
  end

We can insert a record with the atom_type field set to :monkey.

%Curious.TypedTable{atom_type: :monkey} |> Curious.Repo.insert!

And we can query data back out.

Curious.Repo.all(from t in Curious.TypedTable, where: (t.atom_type == ^:monkey))  

One gotcha, notice the use of the pin (^) operator above? We have to use this to interpolate the atom literal into the query. I was stuck for a bit because when you forget the pin the code fails to compile.

== Compilation error on file lib/atom_type_problem.ex ==
** (Ecto.Query.CompileError) `:monkey` is not a valid query expression
    expanding macro: Ecto.Query.where/3
    lib/atom_type_problem.ex:62: Curious.TypedTable.run_example/0
    expanding macro: Ecto.Query.from/2
    lib/atom_type_problem.ex:62: Curious.TypedTable.run_example/0

A huge thanks to José Valim for clearing this up for me...and for so many other things.

I'm not sure what the use case might be for using atoms over strings but it was a fun exercise figuring this out. If you think of a goos one please let me know.

Also a shout out to Karol Wojtaszek (@karol) on the Elixir Slack channel for prompting the idea behind this post.