From a95386c99f1615563eb88b0249cfdecfce2d407b Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Fri, 3 Jul 2026 16:49:39 +0200 Subject: [PATCH 1/6] Add UUID v7 helper functions Adds `timestamp/1`, `datetime/1`, and `version/1` helpers for dealing with UUIDs. --- lib/ecto/uuid.ex | 35 ++++++++++++++++++++++++++++++++++- test/ecto/uuid_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index 5473972eac..0c14db3fc6 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -235,6 +235,9 @@ defmodule Ecto.UUID do * `:precision` - The timestamp precision for version 7 UUIDs. Supported values are `:millisecond` and `:monotonic`. Defaults to `:millisecond`. + * `:timestamp` - A specific unix timestamp to generate the UUID with. + Only works for `precision: :millisecond`. + > #### Monotonic precision {: .info} > > When using `:monotonic`, sub-millisecond precision is encoded in the @@ -293,11 +296,12 @@ defmodule Ecto.UUID do defp bingenerate_v7(opts) do {precision, rest} = Keyword.pop(opts, :precision, :millisecond) + {timestamp, rest} = Keyword.pop(rest, :timestamp) if rest != [], do: raise(ArgumentError, "unsupported options for v7: #{inspect(rest)}") case precision do :millisecond -> - timestamp = System.system_time(:millisecond) + timestamp = timestamp || System.system_time(:millisecond) <> = :crypto.strong_rand_bytes(10) <> @@ -374,4 +378,33 @@ defmodule Ecto.UUID do defp e(13), do: ?d defp e(14), do: ?e defp e(15), do: ?f + + @doc """ + Returns the timestamp from the UUID. Only works for UUID v7. + + Raises `Ecto.ArgumentError` when a UUID is given that's not v7. + """ + def timestamp(<>), do: milliseconds + + def timestamp(<<_::48, version::4, _::76>>), + do: raise(ArgumentError, "timestamp only supports v7 UUIDs, got v#{version}") + + def timestamp(<<_::288>> = uuid), do: uuid |> dump!() |> timestamp() + + @doc """ + Tries to fetch the `DateTime` from the UUID. Only works for UUID v7. + + Raises `Ecto.ArgumentError` when a UUID is given that's not v7. + """ + def datetime(uuid) do + uuid + |> timestamp() + |> DateTime.from_unix!(:millisecond) + end + + @doc """ + Returns the version number for the UUID. + """ + def version(<<_::48, version::4, _::76>>), do: version + def version(<<_::288>> = uuid), do: uuid |> dump!() |> version() end diff --git a/test/ecto/uuid_test.exs b/test/ecto/uuid_test.exs index b50cc1409c..3b7056c31f 100644 --- a/test/ecto/uuid_test.exs +++ b/test/ecto/uuid_test.exs @@ -135,4 +135,29 @@ defmodule Ecto.UUIDTest do Ecto.UUID.generate(version: 7, precision: :monotonic, foo: :bar) end end + + test "timestamp from uuid v7" do + now = DateTime.utc_now() |> DateTime.truncate(:millisecond) + unix = DateTime.to_unix(now, :millisecond) + uuid = Ecto.UUID.generate(version: 7, timestamp: unix) + + assert Ecto.UUID.timestamp(uuid) == unix + assert Ecto.UUID.datetime(uuid) == now + end + + test "timestamp with non-v7 uuid raises an ArgumentError" do + assert_raise ArgumentError, "timestamp only supports v7 UUIDs, got v4", fn -> + Ecto.UUID.generate() + |> Ecto.UUID.timestamp() + end + end + + test "extract version" do + assert 4 = Ecto.UUID.version(Ecto.UUID.generate()) + assert 7 = Ecto.UUID.version(Ecto.UUID.generate(version: 7)) + + # It generalizes to other versions + assert 1 = Ecto.UUID.version("1df7a830-76ee-11f1-ab07-0242ac120002") + assert 8 = Ecto.UUID.version("10b0e08c-acaf-81c1-be47-8e84b7ea32ce") + end end From 6ae0bc5a70d9010b0ee6ab76e4201f5680b507bb Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Fri, 3 Jul 2026 20:52:29 +0200 Subject: [PATCH 2/6] Remove `:timestamp` option & function. Keep `to_datetime`. --- lib/ecto/uuid.ex | 52 ++++++++++++++++++++++++++--------------- test/ecto/uuid_test.exs | 34 +++++++++++++++++++-------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index 0c14db3fc6..88213466ed 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -235,9 +235,6 @@ defmodule Ecto.UUID do * `:precision` - The timestamp precision for version 7 UUIDs. Supported values are `:millisecond` and `:monotonic`. Defaults to `:millisecond`. - * `:timestamp` - A specific unix timestamp to generate the UUID with. - Only works for `precision: :millisecond`. - > #### Monotonic precision {: .info} > > When using `:monotonic`, sub-millisecond precision is encoded in the @@ -296,12 +293,11 @@ defmodule Ecto.UUID do defp bingenerate_v7(opts) do {precision, rest} = Keyword.pop(opts, :precision, :millisecond) - {timestamp, rest} = Keyword.pop(rest, :timestamp) if rest != [], do: raise(ArgumentError, "unsupported options for v7: #{inspect(rest)}") case precision do :millisecond -> - timestamp = timestamp || System.system_time(:millisecond) + timestamp = System.system_time(:millisecond) <> = :crypto.strong_rand_bytes(10) <> @@ -380,31 +376,49 @@ defmodule Ecto.UUID do defp e(15), do: ?f @doc """ - Returns the timestamp from the UUID. Only works for UUID v7. + Tries to fetch the `DateTime` from the UUID. Only works for UUID v7, not v4. + + ## Examples - Raises `Ecto.ArgumentError` when a UUID is given that's not v7. + iex> match?({:ok, %DateTime{}}, Ecto.UUID.to_datetime(Ecto.UUID.generate(version: 7))) + true + + iex> Ecto.UUID.to_datetime(Ecto.UUID.generate()) + {:error, {:unsupported_uuid_version, 4}} + + iex> Ecto.UUID.to_datetime("icecream vendor") + {:error, :invalid_uuid} """ - def timestamp(<>), do: milliseconds + @spec to_datetime(binary()) :: {:ok, DateTime.t()} | {:error, atom()} + def to_datetime(<>), + do: DateTime.from_unix(milliseconds, :millisecond) - def timestamp(<<_::48, version::4, _::76>>), - do: raise(ArgumentError, "timestamp only supports v7 UUIDs, got v#{version}") + def to_datetime(<<_::48, version::4, _::76>>), + do: {:error, {:unsupported_uuid_version, version}} - def timestamp(<<_::288>> = uuid), do: uuid |> dump!() |> timestamp() + def to_datetime(uuid) do + case dump(uuid) do + {:ok, t} -> to_datetime(t) + :error -> {:error, :invalid_uuid} + end + end @doc """ - Tries to fetch the `DateTime` from the UUID. Only works for UUID v7. - - Raises `Ecto.ArgumentError` when a UUID is given that's not v7. + Same as `to_datetime/1` but raises if no valid datetime could be extracted. """ - def datetime(uuid) do - uuid - |> timestamp() - |> DateTime.from_unix!(:millisecond) - end + @spec to_datetime!(binary()) :: DateTime.t() + def to_datetime!(<>), + do: DateTime.from_unix!(milliseconds, :millisecond) + + def to_datetime!(<<_::48, version::4, _::76>>), + do: raise(ArgumentError, "to_datetime! does not support UUID v#{version}") + + def to_datetime!(uuid), do: uuid |> dump!() |> to_datetime!() @doc """ Returns the version number for the UUID. """ + @spec version(binary()) :: 1..8 def version(<<_::48, version::4, _::76>>), do: version def version(<<_::288>> = uuid), do: uuid |> dump!() |> version() end diff --git a/test/ecto/uuid_test.exs b/test/ecto/uuid_test.exs index 3b7056c31f..7fdeb3fc29 100644 --- a/test/ecto/uuid_test.exs +++ b/test/ecto/uuid_test.exs @@ -136,19 +136,25 @@ defmodule Ecto.UUIDTest do end end - test "timestamp from uuid v7" do - now = DateTime.utc_now() |> DateTime.truncate(:millisecond) - unix = DateTime.to_unix(now, :millisecond) - uuid = Ecto.UUID.generate(version: 7, timestamp: unix) + test "to_datetime from uuid v7" do + {uuid, now} = uuidv7_now() + assert {:ok, now} == Ecto.UUID.to_datetime(uuid) + + uuid = Ecto.UUID.generate(version: 7, precision: :monotonic) + assert {:ok, %DateTime{}} = Ecto.UUID.to_datetime(uuid) + end + + test "to_datetime! from uuid v7" do + {uuid, now} = uuidv7_now() + assert now == Ecto.UUID.to_datetime!(uuid) - assert Ecto.UUID.timestamp(uuid) == unix - assert Ecto.UUID.datetime(uuid) == now + uuid = Ecto.UUID.generate(version: 7, precision: :monotonic) + assert %DateTime{} = Ecto.UUID.to_datetime!(uuid) end - test "timestamp with non-v7 uuid raises an ArgumentError" do - assert_raise ArgumentError, "timestamp only supports v7 UUIDs, got v4", fn -> - Ecto.UUID.generate() - |> Ecto.UUID.timestamp() + test "to_datetime! raises on uuid v4" do + assert_raise ArgumentError, "to_datetime! does not support UUID v4", fn -> + Ecto.UUID.to_datetime!(Ecto.UUID.generate()) end end @@ -160,4 +166,12 @@ defmodule Ecto.UUIDTest do assert 1 = Ecto.UUID.version("1df7a830-76ee-11f1-ab07-0242ac120002") assert 8 = Ecto.UUID.version("10b0e08c-acaf-81c1-be47-8e84b7ea32ce") end + + defp uuidv7_now() do + now = DateTime.utc_now() |> DateTime.truncate(:millisecond) + timestamp = DateTime.to_unix(now, :millisecond) + <> = :crypto.strong_rand_bytes(10) + uuid = <> + {uuid, now} + end end From ca2bc0501208184f1229d10dbd3323aacc6530c9 Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Fri, 3 Jul 2026 22:36:43 +0200 Subject: [PATCH 3/6] Keep `to_unix` and `to_datetime`, both raise on invalid data --- lib/ecto/uuid.ex | 38 ++++++++++++-------------------------- test/ecto/uuid_test.exs | 26 +++++++++++++++++--------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index 88213466ed..5e272396ee 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -376,44 +376,30 @@ defmodule Ecto.UUID do defp e(15), do: ?f @doc """ - Tries to fetch the `DateTime` from the UUID. Only works for UUID v7, not v4. + Converts the timestamp in the UUID to a datetime. Only works for UUID v7. - ## Examples - - iex> match?({:ok, %DateTime{}}, Ecto.UUID.to_datetime(Ecto.UUID.generate(version: 7))) - true - - iex> Ecto.UUID.to_datetime(Ecto.UUID.generate()) - {:error, {:unsupported_uuid_version, 4}} - - iex> Ecto.UUID.to_datetime("icecream vendor") - {:error, :invalid_uuid} + Raises `ArgumentError` for non-v7 UUIDs. """ @spec to_datetime(binary()) :: {:ok, DateTime.t()} | {:error, atom()} def to_datetime(<>), - do: DateTime.from_unix(milliseconds, :millisecond) + do: DateTime.from_unix!(milliseconds, :millisecond) def to_datetime(<<_::48, version::4, _::76>>), - do: {:error, {:unsupported_uuid_version, version}} + do: raise(ArgumentError, "to_datetime doesn't support UUID v#{version}") - def to_datetime(uuid) do - case dump(uuid) do - {:ok, t} -> to_datetime(t) - :error -> {:error, :invalid_uuid} - end - end + def to_datetime(uuid), do: uuid |> dump!() |> to_datetime() @doc """ - Same as `to_datetime/1` but raises if no valid datetime could be extracted. + Extracts the unix timestamp from the UUID. Only works for v7 UUIDs. + + Raises `ArgumentError` for non-v7 UUIDs. """ - @spec to_datetime!(binary()) :: DateTime.t() - def to_datetime!(<>), - do: DateTime.from_unix!(milliseconds, :millisecond) + def to_unix(<>), do: unix - def to_datetime!(<<_::48, version::4, _::76>>), - do: raise(ArgumentError, "to_datetime! does not support UUID v#{version}") + def to_unix(<<_::48, version::4, _::76>>), + do: raise(ArgumentError, "to_unix doesn't support UUID v#{version}") - def to_datetime!(uuid), do: uuid |> dump!() |> to_datetime!() + def to_unix(uuid), do: uuid |> dump!() |> to_unix() @doc """ Returns the version number for the UUID. diff --git a/test/ecto/uuid_test.exs b/test/ecto/uuid_test.exs index 7fdeb3fc29..b03df209b1 100644 --- a/test/ecto/uuid_test.exs +++ b/test/ecto/uuid_test.exs @@ -138,23 +138,31 @@ defmodule Ecto.UUIDTest do test "to_datetime from uuid v7" do {uuid, now} = uuidv7_now() - assert {:ok, now} == Ecto.UUID.to_datetime(uuid) + assert now == Ecto.UUID.to_datetime(uuid) uuid = Ecto.UUID.generate(version: 7, precision: :monotonic) - assert {:ok, %DateTime{}} = Ecto.UUID.to_datetime(uuid) + assert %DateTime{} = Ecto.UUID.to_datetime(uuid) end - test "to_datetime! from uuid v7" do + test "to_datetime raises ArgumentError on UUID v4" do + assert_raise ArgumentError, "to_datetime doesn't support UUID v4", fn -> + Ecto.UUID.to_datetime(Ecto.UUID.generate()) + end + end + + test "to_unix" do {uuid, now} = uuidv7_now() - assert now == Ecto.UUID.to_datetime!(uuid) + assert DateTime.to_unix(now, :millisecond) == Ecto.UUID.to_unix(uuid) - uuid = Ecto.UUID.generate(version: 7, precision: :monotonic) - assert %DateTime{} = Ecto.UUID.to_datetime!(uuid) + assert num = Ecto.UUID.to_unix(Ecto.UUID.generate(version: 7)) + assert {:ok, %DateTime{}} = DateTime.from_unix(num, :millisecond) + assert num = Ecto.UUID.to_unix(Ecto.UUID.generate(version: 7, precision: :monotonic)) + assert {:ok, %DateTime{}} = DateTime.from_unix(num, :millisecond) end - test "to_datetime! raises on uuid v4" do - assert_raise ArgumentError, "to_datetime! does not support UUID v4", fn -> - Ecto.UUID.to_datetime!(Ecto.UUID.generate()) + test "to_unix raises ArgumentError on UUID v4" do + assert_raise ArgumentError, "to_unix doesn't support UUID v4", fn -> + Ecto.UUID.to_unix(Ecto.UUID.generate()) end end From c9294dccd41b692ef4bf125116c201e60bf6e848 Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Fri, 3 Jul 2026 22:51:51 +0200 Subject: [PATCH 4/6] Fix specs --- lib/ecto/uuid.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index 5e272396ee..d6c32a2573 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -380,7 +380,7 @@ defmodule Ecto.UUID do Raises `ArgumentError` for non-v7 UUIDs. """ - @spec to_datetime(binary()) :: {:ok, DateTime.t()} | {:error, atom()} + @spec to_datetime(binary()) :: DateTime.t() def to_datetime(<>), do: DateTime.from_unix!(milliseconds, :millisecond) @@ -394,6 +394,7 @@ defmodule Ecto.UUID do Raises `ArgumentError` for non-v7 UUIDs. """ + @spec to_unix(binary()) :: integer() def to_unix(<>), do: unix def to_unix(<<_::48, version::4, _::76>>), From 51dd5673b9d67987f9603664600359adac022d05 Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Sat, 4 Jul 2026 11:23:39 +0200 Subject: [PATCH 5/6] Add precision argument to to_unix --- lib/ecto/uuid.ex | 10 +++++++--- test/ecto/uuid_test.exs | 14 +++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index d6c32a2573..ccbdd3ce5b 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -395,12 +395,16 @@ defmodule Ecto.UUID do Raises `ArgumentError` for non-v7 UUIDs. """ @spec to_unix(binary()) :: integer() - def to_unix(<>), do: unix + def to_unix(uuid, precision \\ :second) - def to_unix(<<_::48, version::4, _::76>>), + def to_unix(<>, precision), + do: System.convert_time_unit(unix, :millisecond, precision) + + def to_unix(<<_::48, version::4, _::76>>, _precision), do: raise(ArgumentError, "to_unix doesn't support UUID v#{version}") - def to_unix(uuid), do: uuid |> dump!() |> to_unix() + def to_unix(uuid, precision), + do: uuid |> dump!() |> to_unix(precision) @doc """ Returns the version number for the UUID. diff --git a/test/ecto/uuid_test.exs b/test/ecto/uuid_test.exs index b03df209b1..98af0fb838 100644 --- a/test/ecto/uuid_test.exs +++ b/test/ecto/uuid_test.exs @@ -152,14 +152,22 @@ defmodule Ecto.UUIDTest do test "to_unix" do {uuid, now} = uuidv7_now() - assert DateTime.to_unix(now, :millisecond) == Ecto.UUID.to_unix(uuid) + assert DateTime.to_unix(now, :millisecond) == Ecto.UUID.to_unix(uuid, :millisecond) - assert num = Ecto.UUID.to_unix(Ecto.UUID.generate(version: 7)) + assert num = Ecto.UUID.to_unix(Ecto.UUID.generate(version: 7), :millisecond) assert {:ok, %DateTime{}} = DateTime.from_unix(num, :millisecond) - assert num = Ecto.UUID.to_unix(Ecto.UUID.generate(version: 7, precision: :monotonic)) + + uuid = Ecto.UUID.generate(version: 7, precision: :monotonic) + assert num = Ecto.UUID.to_unix(uuid, :millisecond) + assert {:ok, %DateTime{}} = DateTime.from_unix(num, :millisecond) end + test "to_unix with precision argument" do + {uuid, now} = uuidv7_now() + assert DateTime.to_unix(now, :second) == Ecto.UUID.to_unix(uuid, :second) + end + test "to_unix raises ArgumentError on UUID v4" do assert_raise ArgumentError, "to_unix doesn't support UUID v4", fn -> Ecto.UUID.to_unix(Ecto.UUID.generate()) From bcc5fbb968c7ed587b5a89ecc9613c28d7cc1c21 Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Mon, 6 Jul 2026 16:12:50 +0200 Subject: [PATCH 6/6] Add precision to spec --- lib/ecto/uuid.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ecto/uuid.ex b/lib/ecto/uuid.ex index ccbdd3ce5b..c74ff19200 100644 --- a/lib/ecto/uuid.ex +++ b/lib/ecto/uuid.ex @@ -394,7 +394,7 @@ defmodule Ecto.UUID do Raises `ArgumentError` for non-v7 UUIDs. """ - @spec to_unix(binary()) :: integer() + @spec to_unix(binary(), System.time_unit()) :: integer() def to_unix(uuid, precision \\ :second) def to_unix(<>, precision),