One thing I found confusing about Postgrex, the excellent PostgreSQL adapter for Elixir, was how to use PostgresSQL-specific data types (cidr, inet, interval, lexeme, range, etc) with Ecto. As far as I can tell, while Postgrex includes structs that can be converted to each type (like Postgrex.INET etc), you still have to write your own Ecto.Type implementation for each type you use in an Ecto schema.
For example, to implement an Ecto schema for a table like the following:
CREATE TABLE hits (
id BIGSERIAL NOT NULL PRIMARY KEY,
url TEXT NOT NULL,
ip INET NOT NULL,
inserted_at TIMESTAMP NOT NULL
);
You'd need minimally to create an Ecto type implementation like the following:
# lib/my_app/inet_type.ex
defmodule MyApp.InetType do
@moduledoc """
`Ecto.Type` implementation for postgres `INET` type.
"""
use Ecto.Type
def type, do: :inet
def cast(term), do: {:ok, term}
def dump(term), do: {:ok, term}
def load(term), do: {:ok, term}
end
So that you could then define the Ecto schema with your custom Ecto type:
# lib/my_app/hits/hit.ex
defmodule MyApp.Hits.Hit do
@moduledoc """
The Hit schema.
"""
use Ecto.Schema
schema "hits" do
field :url, :string
field :ip, MyApp.InetType
timestamps updated_at: false
end
def changeset(hit, attrs) do
hit
|> cast(attrs, [:url, :ip])
|> validate_required([:url, :ip])
end
end
Note that in your migration code, you use the native database type name (eg inet), not your custom type name:
# priv/repo/migrations/202001010000_create_hits.exs
defmodule MyApp.Repo.Migrations.CreateHits do
use Ecto.Migration
def change do
create table(:hits) do
add :url, :text, null: false
add :ip, :inet, null: false
timestamps updated_at: false
end
end
end
A Fancier Type
However, the above basic InetType implementation limits this example Hit schema to working only with Postgrex.INET structs for its ip field — so, while creating a Hit record with the IP address specified via a Postgrex.INET struct works nicely:
iex> (
...> %MyApp.Hits.Hit{}
...> |> MyApp.Hits.Hit.changeset(%{url: "/", ip: %Postgrex.INET{address: {127, 0, 0, 1}}})
...> |> MyApp.Repo.insert!()
...> |> Map.get(:ip)
...> )
%Postgrex.INET{address: {127, 0, 0, 1}}
Creating one with the IP address specified as a string (or even a plain tuple like {127, 0, 0, 1}) won't work:
iex> (
iex> %MyApp.Hits.Hit{}
...> |> MyApp.Hits.Hit.changeset(%{url: "/", ip: "127.0.0.1"}})
...> |> MyApp.Repo.insert!()
...> |> Map.get(:ip)
...> )
** (Ecto.InvalidChangesetError) could not perform insert because changeset is invalid.
This can be solved by implementing a fancier version of the cast function in the MyApp.InetType module, enabling Ecto to cast strings (and tuples) to the Postgrex.INET type. Here's a version of MyApp.InetType that does that, as well as allows the Postgrex.INET struct to be serialized as a string (including when serialized to JSON with the Jason library, or when rendered as part of a Phoenix HTML template):
# lib/my_app/inet_type.ex
defmodule MyApp.InetType do
@moduledoc """
`Ecto.Type` implementation for postgres `INET` type.
"""
use Bitwise
use Ecto.Type
alias Postgrex.INET
def type, do: :inet
def cast(nil), do: {:ok, nil}
def cast(""), do: {:ok, nil}
def cast(%INET{address: nil}), do: {:ok, nil}
def cast(%INET{} = term), do: {:ok, term}
def cast(term) when is_tuple(term), do: {:ok, %INET{address: term}}
def cast(term) when is_binary(term) do
[addr | mask] = String.split(term, "/", parts: 2)
with {:ok, address} <- parse_address(addr),
{:ok, number} <- parse_netmask(mask),
{:ok, netmask} <- validate_netmask(number, address) do
{:ok, %INET{address: address, netmask: netmask}}
else
message -> {:error, [message: message]}
end
end
def cast(_), do: :error
def dump(term), do: {:ok, term}
def load(term), do: {:ok, term}
defp parse_address(addr) do
case :inet.parse_strict_address(String.to_charlist(addr)) do
{:ok, address} -> {:ok, address}
_ -> "not a valid IP address"
end
end
defp parse_netmask([]), do: {:ok, nil}
defp parse_netmask([mask]) do
case Integer.parse(mask) do
{number, ""} -> {:ok, number}
_ -> "not a CIDR netmask"
end
end
defp validate_netmask(nil, _addr), do: {:ok, nil}
defp validate_netmask(mask, _addr) when mask < 0 do
"CIDR netmask cannot be negative"
end
defp validate_netmask(mask, addr) when mask > 32 and tuple_size(addr) == 4 do
"CIDR netmask cannot be greater than 32"
end
defp validate_netmask(mask, _addr) when mask > 128 do
"CIDR netmask cannot be greater than 128"
end
defp validate_netmask(mask, addr) do
ipv4 = tuple_size(addr) == 4
max = if ipv4, do: 32, else: 128
subnet = if ipv4, do: 8, else: 16
bits =
addr
|> Tuple.to_list()
|> Enum.reverse()
|> Enum.with_index()
|> Enum.reduce(0, fn {value, index}, acc -> acc + (value <<< (index * subnet)) end)
bitmask = ((1 <<< max) - 1) ^^^ ((1 <<< (max - mask)) - 1)
if (bits &&& bitmask) == bits do
{:ok, mask}
else
"masked bits of IP address all must be 0s"
end
end
end
defimpl String.Chars, for: Postgrex.INET do
def to_string(%{address: address, netmask: netmask}) do
"#{address_to_string(address)}#{netmask_to_string(netmask)}"
end
defp address_to_string(nil), do: ""
defp address_to_string(address), do: address |> :inet.ntoa()
defp netmask_to_string(nil), do: ""
defp netmask_to_string(netmask), do: "/#{netmask}"
end
defimpl Jason.Encoder, for: Postgrex.INET do
def encode(term, opts), do: term |> to_string() |> Jason.Encode.string(opts)
end
defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
def to_iodata(term), do: term |> to_string()
end
Alternative Canonical Representation
Note that an alternative way of implementing your Ecto type would be to make the dump and load functions round-trip the Postgrex.INET struct to and from some more convenient canonical representation (like a plain string). For example, a MyApp.InetType like the following would allow you to use plain strings to represent IP address values in your schemas (instead of Postgrex.INET structs). It would dump each such string to a Postgrex.INET struct when Ecto attempts to save the value to the database, and load the value from a Postgrex.INET struct into a string when Ecto attempts to load the value from the database:
# lib/my_app/inet_type.ex
defmodule MyApp.InetType do
@moduledoc """
`Ecto.Type` implementation for postgres `INET` type.
"""
use Ecto.Type
alias Postgrex.INET
def type, do: :inet
def cast(nil), do: {:ok, ""}
def cast(term) when is_tuple(term), do: {:ok, address_to_string(term)}
def cast(term) when is_binary(term), do: {:ok, term}
def cast(_), do: :error
def dump(nil), do: {:ok, nil}
def dump(""), do: {:ok, nil}
def dump(term) when is_binary(term) do
[addr | mask] = String.split(term, "/", parts: 2)
with {:ok, address} <- parse_address(addr),
{:ok, number} <- parse_netmask(mask),
{:ok, netmask} <- validate_netmask(number, address) do
{:ok, %INET{address: address, netmask: netmask}}
else
message -> {:error, [message: message]}
end
end
def dump(_), do: :error
def load(nil), do: {:ok, ""}
def load(%INET{address: address, netmask: netmask}) do
"#{address_to_string(address)}#{netmask_to_string(netmask)}"
end
def load(_), do: :error
defp parse_address(addr) do
case :inet.parse_strict_address(String.to_charlist(addr)) do
{:ok, address} -> {:ok, address}
_ -> "not a valid IP address"
end
end
defp parse_netmask([]), do: {:ok, nil}
defp parse_netmask([mask]) do
case Integer.parse(mask) do
{number, ""} -> {:ok, number}
_ -> "not a CIDR netmask"
end
end
defp validate_netmask(nil, _addr), do: {:ok, nil}
defp validate_netmask(mask, _addr) when mask < 0 do
"CIDR netmask cannot be negative"
end
defp validate_netmask(mask, addr) when mask > 32 and tuple_size(addr) == 4 do
"CIDR netmask cannot be greater than 32"
end
defp validate_netmask(mask, _addr) when mask > 128 do
"CIDR netmask cannot be greater than 128"
end
defp validate_netmask(mask, addr) do
ipv4 = tuple_size(addr) == 4
max = if ipv4, do: 32, else: 128
subnet = if ipv4, do: 8, else: 16
bits =
addr
|> Tuple.to_list()
|> Enum.reverse()
|> Enum.with_index()
|> Enum.reduce(0, fn {value, index}, acc -> acc + (value <<< (index * subnet)) end)
bitmask = ((1 <<< max) - 1) ^^^ ((1 <<< (max - mask)) - 1)
if (bits &&& bitmask) == bits do
{:ok, mask}
else
"masked bits of IP address all must be 0s"
end
end
defp address_to_string(nil), do: ""
defp address_to_string(address), do: address |> :inet.ntoa()
defp netmask_to_string(nil), do: ""
defp netmask_to_string(netmask), do: "/#{netmask}"
end
No comments:
Post a Comment