From 818b21642431b6006348c94a9ff06ea3a829dba8 Mon Sep 17 00:00:00 2001
From: Sloane Perrault <sloane@perrault.email>
Date: Thu, 6 Jul 2023 17:03:11 -0400
Subject: [PATCH] add ecto type support

---
 CHANGELOG.md          |  5 +++
 README.md             | 15 +++++++
 lib/type_id.ex        | 91 ++++++++++++++++++++++++++++++++++++++++---
 mix.exs               |  1 +
 mix.lock              |  3 ++
 test/type_id_test.exs | 10 ++---
 6 files changed, 115 insertions(+), 10 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b751195..6200bac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## main
+
+- **BREAKING:** `type/1` has been renamed to `prefix/1`
+- `Ecto.ParameterizedType` implementation
+
 ## 0.2.2
 
 - Lower required Elixir version
diff --git a/README.md b/README.md
index a0f6573..2e76515 100644
--- a/README.md
+++ b/README.md
@@ -23,3 +23,18 @@ end
 ## Spec
 
 The original TypeID spec is defined [here](https://github.com/jetpack-io/typeid).
+
+## Usage with Ecto
+
+`TypeID` implements the `Ecto.ParameterizedType` behaviour so you can use
+TypeIDs as fields in your Ecto schemas.
+
+```elixir
+defmodule MyApp.Accounts.User do
+  use Ecto.Schema
+
+  @primary_key {:id, TypeID, autogenerate: true, prefix: "acct", type: :binary_id}
+
+  # ...
+end
+```
diff --git a/lib/type_id.ex b/lib/type_id.ex
index d6df103..db9db97 100644
--- a/lib/type_id.ex
+++ b/lib/type_id.ex
@@ -34,17 +34,17 @@ defmodule TypeID do
   end
 
   @doc """
-  Returns the type of the given `t:t/0`.
+  Returns the prefix of the given `t:t/0`.
 
   ### Example
 
       iex> tid = TypeID.new("doc")
-      iex> TypeID.type(tid)
+      iex> TypeID.prefix(tid)
       "doc"
 
   """
-  @spec type(tid :: t()) :: String.t()
-  def type(%__MODULE__{prefix: prefix}) do
+  @spec prefix(tid :: t()) :: String.t()
+  def prefix(%__MODULE__{prefix: prefix}) do
     prefix
   end
 
@@ -242,12 +242,93 @@ defmodule TypeID do
 
     :ok
   end
+
+  if Code.ensure_loaded?(Ecto.ParameterizedType) do
+    use Ecto.ParameterizedType
+
+    @impl Ecto.ParameterizedType
+    def init(opts), do: validate_opts!(opts)
+
+    @impl Ecto.ParameterizedType
+    def type(%{type: type}), do: type
+
+    @impl Ecto.ParameterizedType
+    def autogenerate(%{prefix: prefix}) do
+      new(prefix)
+    end
+
+    @impl Ecto.ParameterizedType
+    def cast(nil, _params), do: {:ok, nil}
+    def cast(%__MODULE__{prefix: prefix} = tid, %{prefix: prefix}), do: {:ok, tid}
+
+    def cast(str, %{prefix: prefix}) when is_binary(str) do
+      if String.starts_with?(str, prefix) do
+        from_string(str)
+      else
+        with {:ok, uuid} <- Ecto.UUID.cast(str) do
+          from_uuid(prefix, uuid)
+        end
+      end
+    end
+
+    def cast(_, _), do: :error
+
+    @impl Ecto.ParameterizedType
+    def dump(nil, _dumper, _params), do: {:ok, nil}
+
+    def dump(%__MODULE__{prefix: prefix} = tid, _, %{prefix: prefix, type: :string}) do
+      {:ok, __MODULE__.to_string(tid)}
+    end
+
+    def dump(%__MODULE__{prefix: prefix} = tid, _, %{prefix: prefix, type: :binary_id}) do
+      {:ok, uuid(tid)}
+    end
+
+    def dump(_, _, _), do: :error
+
+    @impl Ecto.ParameterizedType
+    def load(nil, _, _), do: {:ok, nil}
+
+    def load(str, _, %{type: :string, prefix: prefix}) do
+      with {:ok, %__MODULE__{prefix: ^prefix}} = loaded <- from_string(str) do
+        loaded
+      end
+    end
+
+    def load(<<_::128>> = uuid, _, %{type: :binary_id, prefix: prefix}) do
+      from_uuid_bytes(prefix, uuid)
+    end
+
+    def load(<<_::288>> = uuid, _, %{type: :binary_id, prefix: prefix}) do
+      from_uuid(prefix, uuid)
+    rescue
+      _ -> :error
+    end
+
+    def load(_, _, _), do: :error
+
+    defp validate_opts!(opts) do
+      type = Keyword.get(opts, :type, :string)
+      prefix = Keyword.get(opts, :prefix, "")
+
+      unless prefix && prefix =~ ~r/^[a-z]{0,63}$/ do
+        raise ArgumentError,
+              "must specify `prefix` using only lowercase letters between 0 and 63 characters long."
+      end
+
+      unless type in ~w[string binary_id]a do
+        raise ArgumentError, "`type` must be `:string` or `:binary_id`"
+      end
+
+      %{prefix: prefix, type: type}
+    end
+  end
 end
 
 defimpl Inspect, for: TypeID do
   import Inspect.Algebra
 
   def inspect(tid, _opts) do
-    concat(["#TypeID<\"", tid.prefix, "_", tid.suffix, "\">"])
+    concat(["#TypeID<\"", TypeID.to_string(tid), "\">"])
   end
 end
diff --git a/mix.exs b/mix.exs
index 2194e86..abca5eb 100644
--- a/mix.exs
+++ b/mix.exs
@@ -42,6 +42,7 @@ defmodule TypeID.MixProject do
 
   defp deps do
     [
+      {:ecto, "~> 3.10", only: [:dev, :test], optional: true},
       {:ex_doc, "~> 0.27", only: :dev, runtime: false},
       {:yaml_elixir, "~> 2.9", only: [:dev, :test], runtime: false}
     ]
diff --git a/mix.lock b/mix.lock
index 9e8772c..3c45c20 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,10 +1,13 @@
 %{
+  "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
   "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"},
+  "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"},
   "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"},
   "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
   "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},
   "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
+  "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
   "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
   "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
 }
diff --git a/test/type_id_test.exs b/test/type_id_test.exs
index b75dedd..498b8b6 100644
--- a/test/type_id_test.exs
+++ b/test/type_id_test.exs
@@ -6,14 +6,14 @@ defmodule TypeIDTest do
     test "returns a new TypeID struct" do
       tid = TypeID.new("test")
       assert is_struct(tid, TypeID)
-      assert "test" == TypeID.type(tid)
+      assert "test" == TypeID.prefix(tid)
     end
   end
 
-  describe "type/1" do
-    test "returns the type (prefix) of the given TypeID" do
+  describe "prefix/1" do
+    test "returns the prefix of the given TypeID" do
       tid = TypeID.from_string!("test_01h44had5rfswbvpc383ktj0aa")
-      assert "test" == TypeID.type(tid)
+      assert "test" == TypeID.prefix(tid)
     end
   end
 
@@ -60,7 +60,7 @@ defmodule TypeIDTest do
 
   test "verification" do
     tid = TypeID.from_string!("test_01h44yssjcf5daefvfr0yb70s8")
-    assert "test" == TypeID.type(tid)
+    assert "test" == TypeID.prefix(tid)
     assert "018909ec-e64c-795a-a73f-6fc03cb38328" == TypeID.uuid(tid)
   end
 end