Packages

Type-safe, domain-specific object IDs with Ecto integration

Current section

Files

Jump to
trogon_object_id lib trogon union_object_id.ex
Raw

lib/trogon/union_object_id.ex

defmodule Trogon.UnionObjectId do
@moduledoc """
Discriminated union type that can hold any of multiple `Trogon.ObjectId` types.
"""
@using_opts_schema NimbleOptions.new!(
types: [
type: {:list, :atom},
required: true,
doc: "List of ObjectId modules that can be held by this union."
]
)
@doc """
Defines a union type that can hold any of the specified ObjectId types.
Creates a discriminated union that combines multiple ObjectId types into a single field.
The prefix of each ObjectId determines which type it is when parsing from storage.
## Options
#{NimbleOptions.docs(@using_opts_schema)}
## Usage
First, define individual ObjectId types:
defmodule MyApp.TenantId do
use Trogon.ObjectId, object_type: "tenant"
end
defmodule MyApp.SystemId do
use Trogon.ObjectId, object_type: "system"
end
Then, create a union that combines them:
defmodule MyApp.PrincipalId do
use Trogon.UnionObjectId,
types: [MyApp.TenantId, MyApp.SystemId]
end
Use the union:
iex> tenant_id = MyApp.TenantId.new("abc-123")
iex> principal = MyApp.PrincipalId.new(tenant_id)
%MyApp.PrincipalId{id: %MyApp.TenantId{id: "abc-123"}}
iex> MyApp.PrincipalId.parse("tenant_abc-123")
{:ok, %MyApp.PrincipalId{id: %MyApp.TenantId{id: "abc-123"}}}
iex> to_string(principal)
"tenant_abc-123"
## Type Safety
The union preserves the type of the inner ObjectId, so you can pattern match to determine
which variant you have:
iex> case principal.id do
...> %MyApp.TenantId{} -> "Got a tenant!"
...> %MyApp.SystemId{} -> "Got a system!"
...> end
"Got a tenant!"
## Storage Format
The union stores the complete prefixed string (e.g., `"tenant_abc-123"`). The prefix is
essential for type identification when parsing. Without it, the union cannot determine
which type to deserialize to.
iex> MyApp.PrincipalId.to_storage(principal)
"tenant_abc-123"
## Ecto Integration
The union implements `Ecto.Type`, so you can use it directly in Ecto schemas:
defmodule MyApp.Event do
use Ecto.Schema
schema "events" do
field :actor_id, MyApp.PrincipalId
end
end
## Compile-Time Validation
The macro validates your union definition at compile time:
- **Non-empty types list**: At least one ObjectId type must be provided
- **No exact prefix duplicates**: No two types can have the same prefix
> #### Warning {: .warning}
>
> **Prefix Overlaps Are Not Caught at Compile Time**
>
> Compile-time validation only catches **exact prefix duplicates**, not partial overlaps.
> If one prefix is a substring of another, the shorter prefix will match first during parsing,
> causing silent type mismatches.
>
> **Example of the Problem:**
>
> ```elixir
> defmodule AcmeId do
> use Trogon.ObjectId, object_type: "acme" # prefix: "acme_"
> end
>
> defmodule AcmeAdminId do
> use Trogon.ObjectId, object_type: "acme_admin" # prefix: "acme_admin_"
> end
>
> defmodule PrincipalId do
> use Trogon.UnionObjectId, types: [AcmeId, AcmeAdminId]
> end
>
> # This compiles but gives the wrong result:
> PrincipalId.parse("acme_admin_xyz")
> # => {:ok, %PrincipalId{id: %AcmeId{id: "admin_xyz"}}} # WRONG!
> # Should be: %PrincipalId{id: %AcmeAdminId{id: "xyz"}}
> ```
>
> **How to Avoid:**
>
> Design ObjectId prefixes to be semantically distinct and non-overlapping:
> - Good: `"tenant"`, `"system"`, `"service"`
> - Bad: `"acme"`, `"acme_admin"` (one is substring of other)
> - Bad: `"app"`, `"apple"` (one is substring of other)
"""
defmacro __using__(opts) do
# Expand module aliases before validation
types =
opts
|> Keyword.fetch!(:types)
|> Enum.map(&Macro.expand(&1, __CALLER__))
opts = Keyword.put(opts, :types, types)
opts = NimbleOptions.validate!(opts, @using_opts_schema)
types = Keyword.fetch!(opts, :types)
if Enum.empty?(types) do
raise CompileError,
description: "UnionObjectId types list cannot be empty. Provide at least one ObjectId type."
end
# Compile-time check: ensure no overlapping prefixes
validate_no_prefix_collisions(types)
quote location: :keep do
@behaviour Ecto.Type
@type t :: %__MODULE__{id: struct()}
defstruct [:id]
unquote(__generated_new_functions__(types))
unquote(__generated_parse_functions__(types))
unquote(__generated_storage_functions__(types))
unquote(__generated_ecto_cast__())
unquote(__generated_ecto_load__())
unquote(__generated_ecto_dump__())
unquote(__generated_ecto_comparison__())
unquote(__generated_protocols__())
end
end
defp __generated_new_functions__(types) do
quote location: :keep do
@doc """
Wraps an existing ObjectId struct into the union.
The passed struct must be one of the union's types.
## Examples
iex> obj_id = #{unquote(Enum.at(types, 0))}.new("abc-123")
iex> #{inspect(__MODULE__)}.new(obj_id)
%#{inspect(__MODULE__)}{id: %#{unquote(Enum.at(types, 0))}{id: "abc-123"}}
"""
unquote(
for module <- types do
quote do
@spec new(unquote(module).t()) :: t()
def new(%unquote(module){} = id), do: %__MODULE__{id: id}
end
end
)
end
end
defp __generated_parse_functions__(types) do
quote location: :keep do
@doc """
Parses a string by trying each underlying type's parse function.
The string must be in a format recognized by one of the union's types.
## Examples
iex> #{inspect(__MODULE__)}.parse("#{unquote(Enum.at(types, 0)).prefix()}abc-123")
{:ok, %#{inspect(__MODULE__)}{id: %#{unquote(Enum.at(types, 0))}{id: "abc-123"}}}
iex> #{inspect(__MODULE__)}.parse("invalid")
{:error, :invalid_format}
"""
@spec parse(String.t()) :: {:ok, t()} | {:error, atom()}
def parse(""), do: {:error, :invalid_format}
unquote(
for module <- types do
prefix = module.prefix()
quote do
def parse(unquote(prefix) <> id) when id != "" do
case unquote(module).new(id) do
{:ok, inner} -> {:ok, %__MODULE__{id: inner}}
{:error, _} = error -> error
end
end
end
end
)
def parse(_), do: {:error, :invalid_format}
@doc """
Parses a string by trying each underlying type's parse function, raising on failure.
Same as `parse/1` but raises `ArgumentError` if the string is invalid.
## Examples
iex> #{inspect(__MODULE__)}.parse!("#{unquote(Enum.at(types, 0)).prefix()}abc-123")
%#{inspect(__MODULE__)}{id: %#{unquote(Enum.at(types, 0))}{id: "abc-123"}}
iex> #{inspect(__MODULE__)}.parse!("invalid")
** (ArgumentError) invalid #{inspect(__MODULE__)}: "invalid"
"""
@spec parse!(String.t()) :: t()
def parse!(string) when is_binary(string) do
Trogon.UnionObjectId.parse!(__MODULE__, string)
end
end
end
defp __generated_storage_functions__(types) do
quote location: :keep do
@doc """
Converts the union to a storage string.
Uses the inner ObjectId's to_string representation, which includes its full prefix.
## Examples
iex> obj_id = #{unquote(Enum.at(types, 0))}.new("abc-123")
iex> union = #{inspect(__MODULE__)}.new(obj_id)
iex> #{inspect(__MODULE__)}.to_storage(union)
"#{unquote(Enum.at(types, 0)).prefix()}abc-123"
"""
@spec to_storage(t()) :: String.t()
def to_storage(%__MODULE__{id: id}) when is_struct(id), do: Kernel.to_string(id)
end
end
defp __generated_ecto_cast__() do
quote location: :keep do
@impl Ecto.Type
@spec type() :: :string
def type, do: :string
@impl Ecto.Type
@spec cast(any()) :: {:ok, t() | nil} | :error
def cast(nil), do: {:ok, nil}
def cast(""), do: {:ok, nil}
def cast(%__MODULE__{id: id} = value) when is_struct(id), do: {:ok, value}
def cast(value) when is_binary(value) do
Trogon.UnionObjectId.ecto_parse(__MODULE__, value)
end
def cast(_), do: :error
end
end
defp __generated_ecto_load__() do
quote location: :keep do
@impl Ecto.Type
@spec load(any()) :: {:ok, t() | nil} | :error
def load(nil), do: {:ok, nil}
def load(""), do: {:ok, nil}
def load(value) when is_binary(value) do
Trogon.UnionObjectId.ecto_parse(__MODULE__, value)
end
def load(_), do: :error
end
end
defp __generated_ecto_dump__() do
quote location: :keep do
@impl Ecto.Type
@spec dump(any()) :: {:ok, String.t() | nil} | :error
def dump(nil), do: {:ok, nil}
def dump(%__MODULE__{id: id} = value) when is_struct(id), do: {:ok, to_storage(value)}
def dump(_), do: :error
end
end
defp __generated_ecto_comparison__() do
quote location: :keep do
@impl Ecto.Type
@spec equal?(any(), any()) :: boolean()
def equal?(%__MODULE__{id: a}, %__MODULE__{id: b}), do: a == b
def equal?(_, _), do: false
@impl Ecto.Type
@spec embed_as(atom()) :: :self
def embed_as(_format), do: :self
end
end
defp __generated_protocols__() do
quote location: :keep do
defimpl String.Chars do
@moduledoc false
def to_string(%@for{id: id}), do: Kernel.to_string(id)
end
if Code.ensure_loaded?(Jason.Encoder) do
defimpl Jason.Encoder do
@moduledoc false
def encode(%@for{id: id}, opts) do
Jason.Encoder.encode(id, opts)
end
end
end
end
end
@doc false
@spec parse!(module(), String.t()) :: struct()
def parse!(module, string) do
case module.parse(string) do
{:ok, union} -> union
{:error, reason} -> raise Trogon.ObjectId.ValidationError, module: module, value: string, reason: reason
end
end
@doc false
@spec ecto_parse(module(), String.t()) :: {:ok, struct()} | :error
def ecto_parse(module, value) do
case module.parse(value) do
{:ok, union} -> {:ok, union}
{:error, _} -> :error
end
end
defp validate_no_prefix_collisions(types) do
types
|> build_prefix_map()
|> check_duplicate_prefixes()
end
@doc false
defp build_prefix_map(types) do
types
|> Enum.map(&module_with_prefix/1)
|> Enum.group_by(&elem(&1, 1))
end
@doc false
defp module_with_prefix(module) do
{module, module.prefix()}
end
@doc false
defp check_duplicate_prefixes(prefixes_by_type) do
Enum.each(prefixes_by_type, &check_prefix_collision/1)
end
@doc false
defp check_prefix_collision({prefix, modules}) do
if length(modules) > 1 do
raise_prefix_collision_error(prefix, modules)
end
end
@doc false
defp raise_prefix_collision_error(prefix, modules) do
module_names = format_module_names(modules)
raise CompileError,
description: "UnionObjectId prefix collision: #{inspect(prefix)} is used by multiple types: #{module_names}"
end
@doc false
defp format_module_names(modules) do
Enum.map_join(modules, ", ", &elem(&1, 0))
end
end