Custom ID type does not match type :binary_id

Hi!

I’m trying to create a custom ID field for my (RSS/Atom) Feed model. The ID is supposed to be a hash of the feed URL, which is a field of the model as well.

I could be going about this wrong, and I’m very keen on getting some feedback if so. I’ve created a custom Ecto.Type that will be a proxy for a :binary_id. It’ll require a string to be passed (the feed URL in my case) and then hash the string, and also encode/decode the string into binary and back.

As I believed my hashing method was the issue, I edited my code to simply use the Ecto.UUID library underneath. However, the issue remains regardless of whether I use a binary or bitstring type:

value <<193, 9, 196, 77, 174, 162, 186, 72, 139, 190, 5, 61, 28, 3, 245, 39>> for TwigAdmin.News.Feed.id in insert does not match type :binary_id

migration file
defmodule TwigAdmin.Repo.Migrations.CreateFeeds do
  use Ecto.Migration

  def change do
    create table(:feeds, primary_key: false) do
      add :id, :binary_id, primary_key: true, null: false
      add :uri, :string
      add :source_uri, :string
      add :name, :string
      add :type, :string
      add :source_updated, :utc_datetime_usec
      add :last_retrieved, :utc_datetime_usec

      # belongs to a user
      add :user_id, references(:users)

      timestamps()
    end
  end
end
feed id
defmodule TwigAdmin.News.Feed.FeedId do
  use Ecto.Type

  def type, do: :binary_id

  def cast(uri) when is_bitstring(uri) do
    {:ok, encode_id(uri)}
  end

  def cast(_), do: :error

  def dump(id) when is_binary(id) do
    Ecto.UUID.dump(id)
  end

  def dump(_), do: :error

  def load(uri) when is_bitstring(uri) do
    {:ok, encode_id(uri)}
  end

  defp encode_id(uri) do
    {:ok, id} =
      uri
      |> String.trim()
      |> Kernel.then(&:crypto.hash(:md5, &1))
      |> Ecto.UUID.load()
    id
  end
end
feed schema
defmodule TwigAdmin.News.Feed.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      alias TwigAdmin.News.Feed.FeedId

      @primary_key {:id, :binary_id, autogenerate: false}
      @foreign_key_type :id
      # @derive {Phoenix.Param, key: :hash_id}
    end
  end
end

defmodule TwigAdmin.News.Feed do
  use TwigAdmin.News.Feed.Schema
  import Ecto.Changeset

  schema "feeds" do
    field(:source_uri, :string)
    field(:uri, :string)
    field(:name, :string)
    field(:type, :string)
    field(:source_updated, :utc_datetime_usec)
    field(:last_retrieved, :utc_datetime_usec)

    belongs_to :user, TwigAdmin.Accounts.User

    timestamps()
  end

  @doc false
  def validate_changeset(feed, attrs) do
    feed
    |> cast(attrs, [:source_uri, :type])
    |> validate_required([:source_uri, :type])
  end

  @doc false
  def changeset(feed, attrs) do
    feed
    |> cast(attrs, [:source_uri, :uri, :name, :type, :source_updated, :last_retrieved])
    |> validate_required([:source_uri, :uri, :name, :type, :source_updated, :last_retrieved])
    |> put_id()
  end

  defp put_id(%Ecto.Changeset{} = changeset) do
    {:ok, id_str} = FeedId.cast(get_field(changeset, :uri))
    {:ok, id} = FeedId.dump(id_str)

    changeset
    |> validate_required(:uri)
    |> put_change(:id, id)
  end
end

I’ve been bashing my head against this issue for a few days now, so any help would be greatly appreciated. Thank you in advance!

I don’t think these functions are correct. load is supposed to transform the “database type” (the value from the type/0 callback) into the “real type”; for instance, the EctoURI example from the module docs:

This takes an incoming map and turns it into a URI struct. How that map is stored is the responsibility of the adapter.

In this case, the only sensible “real type” still is :binary_id since the hash isn’t reversible. Your load/1 should expect an already-decoded :binary_id value and just return that:

def load(binary_id), do: {:ok, binary_id}

Similarly, dump/1 is expected to transform the “real type” into the “database type” so it’s also just an {:ok, input} wrapper. The output of your custom type’s dump/1 is then passed to the adapter’s handler for the “database type”; calling Ecto.UUID.dump/1 in your dump/1 will produce the error you’re seeing since the second dump doesn’t see the shape it expects.


Zooming out a little, I don’t see any advantage to using a custom Ecto.Type for this situation:

  • both load and dump are no-ops (apart from :ok-wrapping)
  • the type isn’t used in a field declaration

Consider migrating the only thing that TwigAdmin.News.Feed.FeedId does (encode_id/1) to TwigAdmin.News.Feed.put_id, similar to how a User changeset might handle setting a hashed_password field.

Sorry for the late reply. Thank you so much, this worked for me.