defmodule AshPrefixedId.Type do @moduledoc false def cast_input(_uuid_type, _prefix, nil, _constraints), do: {:ok, nil} def cast_input(uuid_type, prefix, input, constraints) do with {:ok, uuid_bin} <- decode_object_id(input, prefix), {:ok, uuid_str} <- Ecto.UUID.load(uuid_bin), {:ok, _uuid} <- uuid_type.cast_input(uuid_str, constraints) do {:ok, input} end end def cast_stored(_uuid_type, _prefix, nil, _constraints), do: {:ok, nil} def cast_stored(_uuid_type, prefix, input, _constraints) when is_binary(input) and byte_size(input) == 16 do # 16-byte binary from PostgreSQL — encode directly to ObjectId. # Database is the source of truth; no need to validate through uuid_type. {:ok, encode_uuid(input, prefix)} end def cast_stored(uuid_type, prefix, input, constraints) do with {:ok, uuid} when is_binary(uuid) <- uuid_type.cast_stored(input, constraints) do {:ok, encode_uuid(input, prefix)} end end def dump_to_native(_uuid_type, _prefix, nil, _constraints), do: {:ok, nil} def dump_to_native(uuid_type, prefix, input, constraints) do case decode_object_id(input, prefix) do {:ok, uuid} -> # Ecto.UUID.dump doesn't support 128-bit binary uuid, so for standard # UUID types we return the binary directly. Custom types get delegated. case uuid_type do Ash.Type.UUID -> {:ok, uuid} Ash.Type.UUIDv7 -> {:ok, uuid} _ -> uuid_type.dump_to_native(uuid, constraints) end {:error, _} -> :error end end def equal?(_, nil, nil), do: true def equal?(_, nil, _), do: false def equal?(_, _, nil), do: false def equal?(_prefix, term1, term2) do with {:ok, _, uuid1} <- decode_object_id(term1), {:ok, _, uuid2} <- decode_object_id(term2) do uuid1 == uuid2 else _ -> false end end def encode_uuid(binary_uuid, prefix) do "#{prefix}_#{:base58.binary_to_base58(binary_uuid)}" end def decode_object_id(input, prefix) do case decode_object_id(input) do {:ok, ^prefix, uuid} -> {:ok, uuid} {:ok, _other, _uuid} -> {:error, "incorrect object prefix"} {:error, reason} -> {:error, reason} end end def decode_object_id(input) when is_binary(input) do case String.split(input, "_") do [prefix, slug] -> case base58_to_binary(slug) do uuid when is_binary(uuid) and byte_size(uuid) == 16 -> {:ok, prefix, uuid} _ -> {:error, :invalid_prefixed_id} end _ -> {:error, :invalid_prefixed_id} end end def decode_object_id(_), do: {:error, :invalid_prefixed_id} # erl_base58 RAISES (ArithmeticError/ArgumentError) on characters outside # the base58 alphabet (0, O, I, l, punctuation, …) instead of returning an # error — but this decode sits on cast paths fed by EXTERNAL input (URL # params), where malformed ids must come back as a tagged error, never a # crash. defp base58_to_binary(slug) do :base58.base58_to_binary(to_charlist(slug)) rescue _ -> :error end def generator(uuid_type, prefix, constraints) do StreamData.repeatedly(fn -> generate(uuid_type, prefix, constraints) end) end def generate(uuid_type, prefix, constraints) do case uuid_type do Ash.Type.UUID -> Ecto.UUID.bingenerate() Ash.Type.UUIDv7 -> Ash.UUIDv7.bingenerate() _ -> # Generic implementation (for possibly custom UUID types) {:ok, bin} = uuid_type.generator(constraints) |> Enum.take(1) |> hd() |> Ecto.UUID.dump() bin end |> encode_uuid(prefix) end end