Using stripe like IDs in Ecto (Elixir)
This week I’ve been slowly learning more about Phoenix and Elixir, after a few false starts things are starting to make more sense.
Part of this has been digging into the world of IDs.
By default (just like in rails), Phoenix supports BIG SERIAL
integer IDs for their primary keys + foreign keys.
It works okay… but if you’ve looked around, there are more options out there (IDs vs UUIDs vs ULIDs).
For me, I wanted to support a stripe like ULID. Something that could be generated outside of the app, but also had the convience of prefixing IDs with a short handle.
Luckily for me, Ecto actually supports prefixing out of the box (or at least it was added in 2020).
Now I’m able to support prefixing on primary ids like:
@primary_key {:id, Ecto.ULID, prefix: "org", autogenerate: true}
I’ve also opted to go with downcasing the ULIDs (normally they are upcased, but it is fully interchangeable).
Now when my ID for an org is created, it looks something like:
org_01fsab1yp4zh97q2pnj7p6x73x
Here’s the code to do it (it’s still fairly messy in parts):
defmodule Ecto.ULID do
@moduledoc """
An Ecto type for ULID strings.
Forked from: https://github.com/woylie/ecto-ulid/blob/03f80432d5aba9b04d4476e4e67129fe2935fa53/lib/ecto/ulid.ex#L1
Supports `prefix` + auto downcasing
"""
use Ecto.ParameterizedType
@prefix_separator "_"
@type t :: binary
@typedoc "A hex-encoded UUID string."
@type uuid :: <<_::288>>
@typedoc "A raw binary representation of a UUID."
@type raw :: <<_::128>>
@spec type(any) :: :uuid
def type(_), do: :uuid
@spec init(keyword) :: map
def init(opts), do: Enum.into(opts, %{})
@doc """
Casts a string to ULID.
"""
def cast(value, %{ prefix: prefix } = _params) when is_binary(value) and byte_size(value) >= 26 do
prefix_with_separator = prefix <> @prefix_separator
value
|> String.split_at(String.length(prefix_with_separator))
|> case do
{^prefix_with_separator, data} -> if valid?(String.upcase(data)) do
{:ok, String.upcase(data)}
else
:error
end
_ -> :error
end
end
def cast(value, params) when is_binary(value) and byte_size(value) >= 26 do
if valid?(String.upcase(value)) do
{:ok, String.upcase(value)}
else
:error
end
end
def cast(_, _), do: :error
@doc """
Same as `cast/1` but raises `Ecto.CastError` on invalid arguments.
"""
def cast!(value, params) do
case cast(value, params) do
{:ok, ulid} -> ulid
:error -> raise Ecto.CastError, type: __MODULE__, value: value
end
end
@doc """
Converts a Crockford Base32 encoded ULID into a binary.
"""
def dump(<<_::bytes-size(26)>> = encoded, _dumper, _params) do
encoded
|> String.upcase()
|> decode()
end
def dump(encoded, _dumper, params) when is_binary(encoded) and byte_size(encoded) > 0 do
prefix = Map.get(params, :prefix)
prefix_with_separator = prefix <> @prefix_separator
encoded
|> String.split_at(String.length(prefix_with_separator))
|> case do
{^prefix_with_separator, data} -> if valid?(String.upcase(data)) do
decode(String.upcase(data))
else
:error
end
_ -> :error
end
end
def dump(nil, _, _), do: {:ok, nil}
def dump(_, _, _), do: :error
@doc """
Converts a binary ULID into a Crockford Base32 encoded string.
"""
def load(<<_::unsigned-size(128)>> = bytes, _, params) do
prefix = Map.get(params, :prefix)
case encode(bytes) do
{:ok, ulid_string} -> {:ok, format_id(ulid_string, prefix)}
:error -> :error
end
end
def load(_, _, _), do: :error
@doc false
def autogenerate(params) do
prefix = Map.get(params, :prefix)
generate()
|> format_id(prefix)
end
@doc """
Generates a Crockford Base32 encoded ULID.
If a value is provided for `timestamp`, the generated ULID will be for the provided timestamp.
Otherwise, a ULID will be generated for the current time.
Arguments:
* `timestamp`: A Unix timestamp with millisecond precision.
"""
def generate(timestamp \\ System.system_time(:millisecond)) do
{:ok, ulid} = encode(bingenerate(timestamp))
ulid
end
@doc """
Generates a binary ULID.
If a value is provided for `timestamp`, the generated ULID will be for the provided timestamp.
Otherwise, a ULID will be generated for the current time.
Arguments:
* `timestamp`: A Unix timestamp with millisecond precision.
"""
def bingenerate(timestamp \\ System.system_time(:millisecond)) do
<<timestamp::unsigned-size(48), :crypto.strong_rand_bytes(10)::binary>>
end
defp format_id(id, nil) do
String.downcase(id)
end
defp format_id(id, prefix) do
String.downcase("#{prefix}#{@prefix_separator}#{id}")
end
defp encode(
<<b1::3, b2::5, b3::5, b4::5, b5::5, b6::5, b7::5, b8::5, b9::5,
b10::5, b11::5, b12::5, b13::5, b14::5, b15::5, b16::5, b17::5,
b18::5, b19::5, b20::5, b21::5, b22::5, b23::5, b24::5, b25::5,
b26::5>>
) do
<<e(b1), e(b2), e(b3), e(b4), e(b5), e(b6), e(b7), e(b8), e(b9), e(b10),
e(b11), e(b12), e(b13), e(b14), e(b15), e(b16), e(b17), e(b18), e(b19),
e(b20), e(b21), e(b22), e(b23), e(b24), e(b25), e(b26)>>
catch
:error -> :error
else
encoded -> {:ok, encoded}
end
defp encode(_), do: :error
@compile {:inline, e: 1}
defp e(0), do: ?0
defp e(1), do: ?1
defp e(2), do: ?2
defp e(3), do: ?3
defp e(4), do: ?4
defp e(5), do: ?5
defp e(6), do: ?6
defp e(7), do: ?7
defp e(8), do: ?8
defp e(9), do: ?9
defp e(10), do: ?A
defp e(11), do: ?B
defp e(12), do: ?C
defp e(13), do: ?D
defp e(14), do: ?E
defp e(15), do: ?F
defp e(16), do: ?G
defp e(17), do: ?H
defp e(18), do: ?J
defp e(19), do: ?K
defp e(20), do: ?M
defp e(21), do: ?N
defp e(22), do: ?P
defp e(23), do: ?Q
defp e(24), do: ?R
defp e(25), do: ?S
defp e(26), do: ?T
defp e(27), do: ?V
defp e(28), do: ?W
defp e(29), do: ?X
defp e(30), do: ?Y
defp e(31), do: ?Z
defp decode(
<<c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8,
c10::8, c11::8, c12::8, c13::8, c14::8, c15::8, c16::8, c17::8,
c18::8, c19::8, c20::8, c21::8, c22::8, c23::8, c24::8, c25::8,
c26::8>>
) do
<<d(c1)::3, d(c2)::5, d(c3)::5, d(c4)::5, d(c5)::5, d(c6)::5, d(c7)::5,
d(c8)::5, d(c9)::5, d(c10)::5, d(c11)::5, d(c12)::5, d(c13)::5, d(c14)::5,
d(c15)::5, d(c16)::5, d(c17)::5, d(c18)::5, d(c19)::5, d(c20)::5,
d(c21)::5, d(c22)::5, d(c23)::5, d(c24)::5, d(c25)::5, d(c26)::5>>
catch
:error -> :error
else
decoded -> {:ok, decoded}
end
defp decode(_), do: :error
@compile {:inline, d: 1}
defp d(?0), do: 0
defp d(?1), do: 1
defp d(?2), do: 2
defp d(?3), do: 3
defp d(?4), do: 4
defp d(?5), do: 5
defp d(?6), do: 6
defp d(?7), do: 7
defp d(?8), do: 8
defp d(?9), do: 9
defp d(?A), do: 10
defp d(?B), do: 11
defp d(?C), do: 12
defp d(?D), do: 13
defp d(?E), do: 14
defp d(?F), do: 15
defp d(?G), do: 16
defp d(?H), do: 17
defp d(?J), do: 18
defp d(?K), do: 19
defp d(?M), do: 20
defp d(?N), do: 21
defp d(?P), do: 22
defp d(?Q), do: 23
defp d(?R), do: 24
defp d(?S), do: 25
defp d(?T), do: 26
defp d(?V), do: 27
defp d(?W), do: 28
defp d(?X), do: 29
defp d(?Y), do: 30
defp d(?Z), do: 31
defp d(_), do: throw(:error)
defp valid?(
<<c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8,
c10::8, c11::8, c12::8, c13::8, c14::8, c15::8, c16::8, c17::8,
c18::8, c19::8, c20::8, c21::8, c22::8, c23::8, c24::8, c25::8,
c26::8>>
) do
v(c1) && v(c2) && v(c3) && v(c4) && v(c5) && v(c6) && v(c7) && v(c8) &&
v(c9) && v(c10) &&
v(c11) && v(c12) && v(c13) &&
v(c14) && v(c15) && v(c16) && v(c17) && v(c18) && v(c19) && v(c20) &&
v(c21) && v(c22) &&
v(c23) && v(c24) && v(c25) && v(c26)
end
defp valid?(_), do: false
@compile {:inline, v: 1}
defp v(?0), do: true
defp v(?1), do: true
defp v(?2), do: true
defp v(?3), do: true
defp v(?4), do: true
defp v(?5), do: true
defp v(?6), do: true
defp v(?7), do: true
defp v(?8), do: true
defp v(?9), do: true
defp v(?A), do: true
defp v(?B), do: true
defp v(?C), do: true
defp v(?D), do: true
defp v(?E), do: true
defp v(?F), do: true
defp v(?G), do: true
defp v(?H), do: true
defp v(?J), do: true
defp v(?K), do: true
defp v(?M), do: true
defp v(?N), do: true
defp v(?P), do: true
defp v(?Q), do: true
defp v(?R), do: true
defp v(?S), do: true
defp v(?T), do: true
defp v(?V), do: true
defp v(?W), do: true
defp v(?X), do: true
defp v(?Y), do: true
defp v(?Z), do: true
defp v(_), do: false
end