From 3d657061f582504ec7db958347675ac289426644 Mon Sep 17 00:00:00 2001 From: Sloane Date: Fri, 7 Jul 2023 09:00:51 -0400 Subject: [PATCH] Add Ecto support (#14) * add ecto type support * Ecto.ParameterizedType, `time: ...` for `new/2` * 0.3.0 --- CHANGELOG.md | 10 ++++ README.md | 17 +++++- lib/type_id.ex | 99 +++++++++++++++++++++++++++++--- mix.exs | 3 +- mix.lock | 3 + test/{ => type_id}/spec_test.exs | 0 test/type_id_test.exs | 21 +++++-- 7 files changed, 138 insertions(+), 15 deletions(-) rename test/{ => type_id}/spec_test.exs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b751195..067ded0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## main + +- **BREAKING:** `type/1` has been renamed to `prefix/1` +- `Ecto.ParameterizedType` implementation +- `new/2` now accepts an optional keyword list to specify the UUID `time:` in unix milliseconds + ```elixir + iex> TypeID.new("test", time: 0) + #TypeID<"test_0000000000fq893mf5039xea5j"> + ``` + ## 0.2.2 - Lower required Elixir version diff --git a/README.md b/README.md index a0f6573..ca76348 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The package can be installed from [hex](https://hex.pm/packages/typeid_elixir) b ```elixir def deps do [ - {:typeid_elixir, "~> 0.2.2"} + {:typeid_elixir, "~> 0.3.0"} ] end ``` @@ -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..f74db2c 100644 --- a/lib/type_id.ex +++ b/lib/type_id.ex @@ -18,6 +18,9 @@ defmodule TypeID do @doc """ Generates a new `t:t/0` with the given prefix. + **Optional**: Specify the time of the UUID v7 by passing + `time: unix_millisecond_time` as the second argument. + ### Example iex> TypeID.new("acct") @@ -25,26 +28,27 @@ defmodule TypeID do """ @spec new(prefix :: String.t()) :: t() - def new(prefix) do + @spec new(prefix :: String.t(), Keyword.t()) :: t() + def new(prefix, opts \\ []) do suffix = - UUID.uuid7() + UUID.uuid7(opts) |> Base32.encode() %__MODULE__{prefix: prefix, suffix: suffix} 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 +246,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..752161d 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule TypeID.MixProject do use Mix.Project - @version "0.2.2" + @version "0.3.0" def project do [ @@ -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/spec_test.exs b/test/type_id/spec_test.exs similarity index 100% rename from test/spec_test.exs rename to test/type_id/spec_test.exs diff --git a/test/type_id_test.exs b/test/type_id_test.exs index b75dedd..980d63f 100644 --- a/test/type_id_test.exs +++ b/test/type_id_test.exs @@ -1,19 +1,28 @@ defmodule TypeIDTest do use ExUnit.Case - doctest TypeID, except: [new: 1] + doctest TypeID, except: [new: 2] describe "new/1" 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 "new/2" do + test "allows setting the time" do + time = ~U[1950-12-17 00:00:00Z] |> DateTime.to_unix(:millisecond) + tid = TypeID.new("test", time: time) + assert "test" == TypeID.prefix(tid) + assert "7zegbdn300" <> _ = TypeID.suffix(tid) + end + end + + 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 +69,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