February 21, 2018

Ecto type for IPv4 and IPv6 addresses

Sometimes we want to store some type of data in the project's database that currently is not available as a valid internal data type to be persisted.

For example, IP addresses, that are structured as tuples and Postgres does not support them. Take as an example the functions from :inet module, that comes with Elixir through Erlang:

:inet.getifaddrs()
:inet.parse_address('127.0.0.1')
:inet.ntoa({127, 0, 0, 1})

In all of them, the IP address data is returned as ip_address type, that it is an union type of ip4_address or ip6_address.

# ip4_address
{0..255, 0..255, 0..255, 0..255}

# ip6_address
{0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535}

Another example is how %Plug.Conn{} holds IP data:

# Other Plug.Conn fields omitted for clarity.

%Plug.Conn{
  ...
  remote_ip: {127, 0, 0, 1},
  ...
}

In the most of cases, storing IP addresses as strings in the database is enough, as we usually display as strings as well, but we need to have a way to translate back and forth tuple to string every time.

One of the solutions is rely on the functions in the module :inet and create some kind of helper function to do the translation from tuple to string every time we want to create or update an IP field. This can become painful in the long-term and error prone.

A better solution is leverage Ecto.Type behaviour that allows us to add custom types in our application and to define how the custom type will interact with the database (for reads and writes).

Below is the module that implements an Ecto.Type for IP addresses, the comments in the @doc explain each callback:

defmodule MyApp.Ecto.IP do
  @moduledoc """
  Implements Ecto.Type behavior for storing IP (either v4 or v6) data that originally comes as tuples.
  """

  @behaviour Ecto.Type

  @doc """
  Defines what internal database type is used.
  """
  def type, do: :string

  @doc """
  As we don't have any special casting rules, simply pass the value.
  """
  def cast(value), do: {:ok, value}

  @doc """
  Loads the IP as string from the database and coverts to a tuple.
  """
  def load(value) do
    value
    |> to_charlist()
    |> :inet.parse_address()
  end

  @doc """
  Receives IP as a tuple and converts to string. In case IP is not a tuple returns an error.
  """
  def dump(value) when is_tuple(value) do
    ip =
      value
      |> :inet.ntoa()
      |> to_string()

    {:ok, ip}
  end

  def dump(_), do: :error
end

In the schema you want to use the custom IP type you simply use the module that implements it as field type:

defmodule MyApp.Request do
  use Ecto.Schema

  schema "requests" do
    field(:remote_ip, MyApp.IP)
    # other fields omitted for clarity.
  end

  # remaining code omitted for clarity.
end

I hope this post can demonstrate one the nice things about Ecto that is extensibility. It helps us to represent well the application specifics without being too dependent of the database current features or driving us to creative workarounds.