From c9eb03b44e4d6e09dbcabdb66f739cc20b4a1560 Mon Sep 17 00:00:00 2001 From: Sloane Date: Sun, 16 Jul 2023 11:01:51 -0400 Subject: [PATCH] Better support for Ecto (#21) * use schema associations to derive prefixes * expand documention for ecto usage * 0.5.0 --- .gitignore | 1 + CHANGELOG.md | 5 ++ README.md | 18 ++++++- lib/type_id.ex | 71 +++----------------------- lib/type_id/ecto.ex | 118 ++++++++++++++++++++++++++++++++++++++++++++ mix.exs | 2 +- 6 files changed, 148 insertions(+), 67 deletions(-) create mode 100644 lib/type_id/ecto.ex diff --git a/.gitignore b/.gitignore index cb39c22..f284635 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ typeid-*.tar # Temporary files, for example, from tests. /tmp/ +/.elixir-tools diff --git a/CHANGELOG.md b/CHANGELOG.md index 09947f0..824beba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.5.0 + +- `Ecto.ParameterizedType` implementation traverses associations so prefixes only need to be defined on schema primary keys +- `Ecto.ParameterizedType` implementation `type` option can be set globally with a `default_type` Application configuration + ## 0.4.0 - Implements `Jason.Encoder` protocol diff --git a/README.md b/README.md index 1edf43e..63f4b0b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The package can be installed from [hex](https://hex.pm/packages/typeid_elixir) b ```elixir def deps do [ - {:typeid_elixir, "~> 0.4.0"} + {:typeid_elixir, "~> 0.5.0"} ] end ``` @@ -32,7 +32,23 @@ defmodule MyApp.Accounts.User do use Ecto.Schema @primary_key {:id, TypeID, autogenerate: true, prefix: "acct", type: :binary_id} + @foreign_key_type TypeID # ... end ``` + +### Underlying types + +`TypeID`s can be stored as either `:string` or `:binary_id`. `:string` will +store the entire TypeID including the prefix. `:binary_id` stores only the +UUID portion and requires a `:uuid` or `:binary` column. + +#### Default type + +The type used can be set globally in the application config. + +```elixir +config :typeid_elixir, + default_type: :binary_id +``` diff --git a/lib/type_id.ex b/lib/type_id.ex index cece53c..53eaaee 100644 --- a/lib/type_id.ex +++ b/lib/type_id.ex @@ -275,81 +275,22 @@ defmodule TypeID do use Ecto.ParameterizedType @impl Ecto.ParameterizedType - def init(opts), do: validate_opts!(opts) + defdelegate init(opts), to: TypeID.Ecto @impl Ecto.ParameterizedType - def type(%{type: type}), do: type + defdelegate type(params), to: TypeID.Ecto @impl Ecto.ParameterizedType - def autogenerate(%{prefix: prefix}) do - new(prefix) - end + defdelegate autogenerate(params), to: TypeID.Ecto @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 + defdelegate cast(data, params), to: TypeID.Ecto @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 + defdelegate dump(data, dumper, params), to: TypeID.Ecto @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 + defdelegate load(data, loader, params), to: TypeID.Ecto end end diff --git a/lib/type_id/ecto.ex b/lib/type_id/ecto.ex new file mode 100644 index 0000000..a32f18f --- /dev/null +++ b/lib/type_id/ecto.ex @@ -0,0 +1,118 @@ +if Code.ensure_loaded?(Ecto.ParameterizedType) do + defmodule TypeID.Ecto do + @moduledoc false + + @doc false + def init(opts), do: validate_opts!(opts) + + @doc false + def type(%{type: type}), do: type + + @doc false + def autogenerate(params) do + params + |> find_prefix() + |> TypeID.new() + end + + @doc false + def cast(nil, _params), do: {:ok, nil} + + def cast(%TypeID{prefix: prefix} = tid, params) do + if prefix == find_prefix(params) do + {:ok, tid} + else + :error + end + end + + def cast(str, params) when is_binary(str) do + prefix = find_prefix(params) + + if String.starts_with?(str, prefix) do + TypeID.from_string(str) + else + with {:ok, uuid} <- Ecto.UUID.cast(str) do + TypeID.from_uuid(prefix, uuid) + end + end + end + + def cast(_, _), do: :error + + @doc false + def dump(nil, _dumper, _params), do: {:ok, nil} + + def dump(%TypeID{} = tid, _, %{type: type} = params) do + prefix = find_prefix(params) + + case {tid.prefix, type} do + {^prefix, :string} -> {:ok, TypeID.to_string(tid)} + {^prefix, :binary_id} -> {:ok, TypeID.uuid(tid)} + _ -> :error + end + end + + def dump(_, _, _), do: :error + + @doc false + def load(nil, _, _), do: {:ok, nil} + + def load(str, _, %{type: :string} = params) do + prefix = find_prefix(params) + + with {:ok, %TypeID{prefix: ^prefix}} = loaded <- TypeID.from_string(str) do + loaded + end + end + + def load(<<_::128>> = uuid, _, %{type: :binary_id} = params) do + prefix = find_prefix(params) + TypeID.from_uuid_bytes(prefix, uuid) + end + + def load(<<_::288>> = uuid, _, %{type: :binary_id} = params) do + prefix = find_prefix(params) + TypeID.from_uuid(prefix, uuid) + rescue + _ -> :error + end + + def load(_, _, _), do: :error + + defp validate_opts!(opts) do + primary_key = Keyword.get(opts, :primary_key, false) + schema = Keyword.fetch!(opts, :schema) + field = Keyword.fetch!(opts, :field) + default_type = Application.get_env(:typeid_elixir, :default_type, :string) + type = Keyword.get(opts, :type, default_type) + prefix = Keyword.get(opts, :prefix, "") + + if primary_key do + 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 + end + + unless type in ~w[string binary_id]a do + raise ArgumentError, "`type` must be `:string` or `:binary_id`" + end + + if primary_key do + %{primary_key: primary_key, schema: schema, field: field, prefix: prefix, type: type} + else + %{schema: schema, field: field, type: type} + end + end + + defp find_prefix(%{prefix: prefix}), do: prefix + + defp find_prefix(%{schema: schema, field: field}) do + %{related: schema, related_key: field} = schema.__schema__(:association, field) + {:parameterized, TypeID, %{prefix: prefix}} = schema.__schema__(:type, field) + + prefix + end + end +end diff --git a/mix.exs b/mix.exs index 31c818e..ffad24c 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule TypeID.MixProject do use Mix.Project - @version "0.4.0" + @version "0.5.0" def project do [