« back to blog

Custom ecto types

Custom types in Ecto are a really nice way of abstracting away some functionality you need in a lot of places concerning your schemas. That sounds really nice, but let's break that down to something more digestible.

Introduction / Why custom ecto types?

Assume you have a task where you need to transform data each time before saving it to your data store. So, maybe you want to store a password and want to generate a cryptographic hash of the value, or you need a customized date format. There's a few ways to do this:

You could write custom queries to your data store, but that would mean you would have to also write more code to stitch together your result set; you could manipulate your data every time before transferring it to your schema, but that would make it hard to track your transformations and scatter your code all over the place.

Custom types are basically getters and setters, and are thus the ideal place to put that kind of functionality.

So, let's examine one example case we've had in Sealas so far: transform any hashed string to a UUID to make it indexable on PostgreSQL. This will also provide us with a reusable pattern for more cases like this.

Testing and writing custom ecto types

Consulting the documentation we see that we need 4 functions in our custom type:

  • type
  • cast
  • dump
  • load

We'll start out by writing a - failing - test case for what we want to achieve. The most important one is the cast function, as this is where the actual conversion happens.

    defmodule SealasApi.EctoHashIndexTest do
      use SealasApi.DataCase

      @test_invoice_uuid "c13bbe22-f8f6-55a0-47af-313e82edfbbd"

      describe "casting custom ecto hash type" do
        test "cast" do
          assert EctoHashIndex.cast("test_invoice") == {:ok, @test_invoice_uuid}
        end
      end
    end

Starting out with the basics, I define some test data here, a UUID to match against. This you could get out of Postgres with the SQL query SELECT md5('any_string_here')::uuid. With that test data at hand we can go for the first function. Casts return a tuple with the structure {:ok, data}, so that's what we test for.

The other simple test is for the type. We want the type to be a UUID:

    test "type is uuid" do
      assert EctoHashIndex.type == Ecto.UUID
    end

These tests will fail of course, since we don't have the actual type implemented yet, so let's go ahead and implement it.

    defmodule EctoHashIndex do
      @behaviour Ecto.Type
      def type, do: Ecto.UUID

      def cast(string) when is_binary(string) do
        Ecto.UUID.cast :crypto.hash(:md5, string)
      end
      def cast(uuid), do: Ecto.UUID.cast uuid
    end

With this we've got our basic necessities set up, the behaviour for Ecto.Type, the type UUID itself for the database, and a basic cast function. The only thing I really want to do here is make every string passed to our HashIndex type converted to a UUID, so we convert it to an md5 hash first and then pass it on to the original UUID ecto type.

Everything works out and passes the tests, however passing UUIDs causes them to be transformed again, so I added a check for UUIDs being left alone.

    test "cast uuid" do
      assert EctoHashIndex.cast(@test_invoice_uuid) == {:ok, @test_invoice_uuid}
    end

with the then corresponding implementation:

    def cast(string) when is_binary(string) do
      case Regex.run(~r/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/, string) do
        nil -> Ecto.UUID.cast :crypto.hash(:md5, string)

        [_] -> {:ok, string}
      end
    end

UUIDs always follow the format 8-4-4-4-12 hexadecimal digits, so a simple regex should do the trick here.

The remaining two functions, load and dump don't need any custom functionality, so we'll use Elixir's handy shorthand defdelegate to just call Ecto.Type.UUID's implementation of those.

    defmodule SealasApi.EctoHashIndexTest do
      use SealasApi.DataCase

      @test_invoice_uuid "c13bbe22-f8f6-55a0-47af-313e82edfbbd"
      @test_invoice_uuid_binary <<193, 59, 190, 34, 248, 246, 85, 160, 71, 175, 49, 62, 130, 237, 251, 189>>

      describe "casting custom ecto hash type" do
        test "cast" do
          assert EctoHashIndex.cast("test_invoice") == {:ok, @test_invoice_uuid}
        end

        test "cast uuid" do
          assert EctoHashIndex.cast(@test_invoice_uuid) == {:ok, @test_invoice_uuid}
        end

        test "dump" do
          {:ok, hash} = EctoHashIndex.cast("test_invoice")

          assert EctoHashIndex.dump(hash) == {:ok, @test_invoice_uuid_binary}
        end

        test "load" do
          assert EctoHashIndex.load(@test_invoice_uuid_binary) == EctoHashIndex.cast("test_invoice")
        end
      end
    end

    defmodule EctoHashIndex do
      @behaviour Ecto.Type
      def type, do: Ecto.UUID

      def cast(string) when is_binary(string) do
        case Regex.run(~r/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/, string) do
          nil -> Ecto.UUID.cast :crypto.hash(:md5, string)

          [_] -> {:ok, string}
        end
      end
      def cast(uuid), do: Ecto.UUID.cast uuid

      defdelegate load(data), to: Ecto.UUID
      defdelegate dump(data), to: Ecto.UUID
    end
Share this Post: