Skip to content

Commit e6da70d

Browse files
authored
generate uuid v7 (#4681)
1 parent 6b61f9a commit e6da70d

File tree

4 files changed

+103
-18
lines changed

4 files changed

+103
-18
lines changed

lib/ecto/schema.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ defmodule Ecto.Schema do
665665
666666
* `:autogenerate` - a `{module, function, args}` tuple for a function
667667
to call to generate the field value before insertion if value is not set.
668+
A list of options is passed as first argument `{type, :autogenerate, [options]}`.
668669
A shorthand value of `true` is equivalent to `{type, :autogenerate, []}`.
669670
670671
* `:read_after_writes` - When true, the field is always read back
@@ -1594,6 +1595,8 @@ defmodule Ecto.Schema do
15941595
as `@primary_key` (see the [Schema attributes](#module-schema-attributes)
15951596
section for more info). Primary keys are automatically set up for embedded
15961597
schemas as well, defaulting to `{:id, :binary_id, autogenerate: true}`.
1598+
This will generate the default UUID v4. You can use UUID v7 instead by setting
1599+
the primary key to `{:id, :binary_id, autogenerate: [version: 7]}`
15971600
Note `:primary_key`s are not automatically read back on `insert/2`,
15981601
unless one of `autogenerate: true` or `read_after_writes: true` is set.
15991602
@@ -2042,6 +2045,9 @@ defmodule Ecto.Schema do
20422045
{_, _, _} ->
20432046
store_mfa_autogenerate!(mod, name, type, gen)
20442047

2048+
autogenerate_opts when is_list(autogenerate_opts) ->
2049+
store_mfa_autogenerate!(mod, name, type, {type, :autogenerate, [autogenerate_opts]})
2050+
20452051
true ->
20462052
store_type_autogenerate!(mod, name, source || name, type, pk?)
20472053

lib/ecto/uuid.ex

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
defmodule Ecto.UUID do
22
@moduledoc """
33
An Ecto type for UUID strings.
4+
5+
## Autogeneration
6+
7+
This type can be used for any UUID field in your schemas.
8+
It is used when autogenerating binary IDs in Ecto.Schema.
9+
By default, autogenerated UUIDs use version 4 (random):
10+
11+
use Ecto.Schema
12+
@primary_key {:id, :binary_id, autogenerate: true}
13+
14+
To use UUID v7 (time-ordered) instead:
15+
16+
use Ecto.Schema
17+
@primary_key {:id, :binary_id, autogenerate: [version: 7]}
418
"""
519

620
use Ecto.Type
@@ -15,6 +29,11 @@ defmodule Ecto.UUID do
1529
"""
1630
@type raw :: <<_::128>>
1731

32+
@typedoc """
33+
currently supported option is version, it accepts 4 or 7.
34+
"""
35+
@type options :: [version: 4 | 7]
36+
1837
@doc false
1938
def type, do: :uuid
2039

@@ -47,6 +66,7 @@ defmodule Ecto.UUID do
4766
"""
4867
@spec cast(t | raw | any) :: {:ok, t} | :error
4968
def cast(uuid)
69+
5070
def cast(
5171
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2, d3,
5272
d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
@@ -105,6 +125,7 @@ defmodule Ecto.UUID do
105125
"""
106126
@spec dump(uuid_string :: t | any) :: {:ok, raw} | :error
107127
def dump(uuid_string)
128+
108129
def dump(
109130
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2, d3,
110131
d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
@@ -183,24 +204,41 @@ defmodule Ecto.UUID do
183204
end
184205
end
185206

207+
@default_version 4
186208
@doc """
187-
Generates a random, version 4 UUID.
209+
Generates a uuid with the given options.
188210
"""
189211
@spec generate() :: t
190-
def generate(), do: encode(bingenerate())
212+
@spec generate(options) :: t
213+
def generate(opts \\ []), do: encode(bingenerate(opts))
191214

192215
@doc """
193-
Generates a random, version 4 UUID in the binary format.
216+
Generates a uuid with the given options in binary format.
194217
"""
195-
@spec bingenerate() :: raw
196-
def bingenerate() do
218+
@spec bingenerate(options) :: raw
219+
def bingenerate(opts \\ []) do
220+
case Keyword.get(opts, :version, @default_version) do
221+
4 -> bingenerate_v4()
222+
7 -> bingenerate_v7()
223+
version -> raise ArgumentError, "unknown UUID version: #{inspect(version)}"
224+
end
225+
end
226+
227+
defp bingenerate_v4 do
197228
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
198229
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
199230
end
200231

232+
defp bingenerate_v7 do
233+
milliseconds = System.system_time(:millisecond)
234+
<<u0::12, u1::62, _::6>> = :crypto.strong_rand_bytes(10)
235+
236+
<<milliseconds::48, 7::4, u0::12, 2::2, u1::62>>
237+
end
238+
201239
# Callback invoked by autogenerate fields.
202240
@doc false
203-
def autogenerate, do: generate()
241+
def autogenerate(opts \\ []), do: generate(opts)
204242

205243
@spec encode(raw) :: t
206244
defp encode(

test/ecto/repo/autogenerate_test.exs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ defmodule Ecto.Repo.AutogenerateTest do
3333

3434
schema "default" do
3535
field :code, Ecto.UUID, autogenerate: true
36+
field :uuid_v4, Ecto.UUID, autogenerate: [version: 4]
37+
field :uuid_v7, Ecto.UUID, autogenerate: [version: 7]
3638
has_one :manager, Manager
3739
has_many :offices, Office
3840
timestamps()
@@ -117,6 +119,7 @@ defmodule Ecto.Repo.AutogenerateTest do
117119
def load(id, _, %{prefix: prefix}), do: {:ok, prefix <> @separator <> to_string(id)}
118120

119121
def dump(nil, _, _), do: {:ok, nil}
122+
120123
def dump(data, _, %{prefix: _prefix}),
121124
do: {:ok, data |> String.split(@separator) |> List.last() |> Integer.parse()}
122125
end
@@ -160,6 +163,22 @@ defmodule Ecto.Repo.AutogenerateTest do
160163
assert byte_size(code_uuid) == 36
161164
end
162165

166+
test "autogenerates uuid v4 and v7 values" do
167+
schema = TestRepo.insert!(%Company{})
168+
assert byte_size(schema.uuid_v4) == 36
169+
assert byte_size(schema.uuid_v7) == 36
170+
171+
changeset = Ecto.Changeset.cast(%Company{}, %{}, [])
172+
schema = TestRepo.insert!(changeset)
173+
assert byte_size(schema.uuid_v4) == 36
174+
assert byte_size(schema.uuid_v7) == 36
175+
176+
changeset = Ecto.Changeset.cast(%Company{}, %{uuid_v4: nil, uuid_v7: nil}, [])
177+
schema = TestRepo.insert!(changeset)
178+
assert byte_size(schema.uuid_v4) == 36
179+
assert byte_size(schema.uuid_v7) == 36
180+
end
181+
163182
## Timestamps
164183

165184
test "sets inserted_at and updated_at values" do
@@ -193,24 +212,27 @@ defmodule Ecto.Repo.AutogenerateTest do
193212
end
194213

195214
test "does not update updated_at when the associated record did not change" do
196-
company = TestRepo.insert!(%Company{offices: [%Office{id: 1, name: "1"}, %Office{id: 2, name: "2"}]})
215+
company =
216+
TestRepo.insert!(%Company{offices: [%Office{id: 1, name: "1"}, %Office{id: 2, name: "2"}]})
217+
197218
[office_one, office_two] = company.offices
198219

199220
changes = %{offices: [%{id: 1, name: "updated"}, %{id: 2, name: "2"}]}
221+
200222
updated_company =
201223
company
202224
|> Ecto.Changeset.cast(changes, [])
203225
|> Ecto.Changeset.cast_assoc(:offices)
204226
|> TestRepo.update!()
227+
205228
[updated_office_one, updated_office_two] = updated_company.offices
206229
assert updated_office_one.updated_at != office_one.updated_at
207230
assert updated_office_two.updated_at == office_two.updated_at
208231
end
209232

210233
test "does not set inserted_at and updated_at values if they were previously set" do
211234
naive_datetime = ~N[2000-01-01 00:00:00]
212-
default = TestRepo.insert!(%Company{inserted_at: naive_datetime,
213-
updated_at: naive_datetime})
235+
default = TestRepo.insert!(%Company{inserted_at: naive_datetime, updated_at: naive_datetime})
214236
assert default.inserted_at == naive_datetime
215237
assert default.updated_at == naive_datetime
216238

@@ -226,7 +248,7 @@ defmodule Ecto.Repo.AutogenerateTest do
226248
assert %DateTime{time_zone: "Etc/UTC", microsecond: {0, 0}} = default.updated_on
227249
assert default.created_on == default.updated_on
228250

229-
default = TestRepo.update!(%Manager{id: 1} |> Ecto.Changeset.change, force: true)
251+
default = TestRepo.update!(%Manager{id: 1} |> Ecto.Changeset.change(), force: true)
230252
refute default.created_on
231253
assert %DateTime{time_zone: "Etc/UTC", microsecond: {0, 0}} = default.updated_on
232254
end
@@ -237,7 +259,7 @@ defmodule Ecto.Repo.AutogenerateTest do
237259
assert %NaiveDateTime{microsecond: {0, 0}} = default.updated_at
238260
assert default.inserted_at == default.updated_at
239261

240-
default = TestRepo.update!(%NaiveMod{id: 1} |> Ecto.Changeset.change, force: true)
262+
default = TestRepo.update!(%NaiveMod{id: 1} |> Ecto.Changeset.change(), force: true)
241263
refute default.inserted_at
242264
assert %NaiveDateTime{microsecond: {0, 0}} = default.updated_at
243265
end
@@ -248,7 +270,7 @@ defmodule Ecto.Repo.AutogenerateTest do
248270
assert %NaiveDateTime{microsecond: {_, 6}} = default.updated_at
249271
assert default.inserted_at == default.updated_at
250272

251-
default = TestRepo.update!(%NaiveUsecMod{id: 1} |> Ecto.Changeset.change, force: true)
273+
default = TestRepo.update!(%NaiveUsecMod{id: 1} |> Ecto.Changeset.change(), force: true)
252274
refute default.inserted_at
253275
assert %NaiveDateTime{microsecond: {_, 6}} = default.updated_at
254276
end
@@ -259,7 +281,7 @@ defmodule Ecto.Repo.AutogenerateTest do
259281
assert %DateTime{time_zone: "Etc/UTC", microsecond: {0, 0}} = default.updated_at
260282
assert default.inserted_at == default.updated_at
261283

262-
default = TestRepo.update!(%UtcMod{id: 1} |> Ecto.Changeset.change, force: true)
284+
default = TestRepo.update!(%UtcMod{id: 1} |> Ecto.Changeset.change(), force: true)
263285
refute default.inserted_at
264286
assert %DateTime{time_zone: "Etc/UTC", microsecond: {0, 0}} = default.updated_at
265287
end
@@ -270,7 +292,7 @@ defmodule Ecto.Repo.AutogenerateTest do
270292
assert %DateTime{time_zone: "Etc/UTC", microsecond: {_, 6}} = default.updated_at
271293
assert default.inserted_at == default.updated_at
272294

273-
default = TestRepo.update!(%UtcUsecMod{id: 1} |> Ecto.Changeset.change, force: true)
295+
default = TestRepo.update!(%UtcUsecMod{id: 1} |> Ecto.Changeset.change(), force: true)
274296
refute default.inserted_at
275297
assert %DateTime{time_zone: "Etc/UTC", microsecond: {_, 6}} = default.updated_at
276298
end

test/ecto/uuid_test.exs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ defmodule Ecto.UUIDTest do
77
@test_uuid_invalid_characters "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
88
@test_uuid_invalid_format "xxxxxxxx-xxxx"
99
@test_uuid_null "00000000-0000-0000-0000-000000000000"
10-
@test_uuid_binary <<0x60, 0x1D, 0x74, 0xE4, 0xA8, 0xD3, 0x4B, 0x6E,
11-
0x83, 0x65, 0xED, 0xDB, 0x4C, 0x89, 0x33, 0x27>>
10+
@test_uuid_binary <<0x60, 0x1D, 0x74, 0xE4, 0xA8, 0xD3, 0x4B, 0x6E, 0x83, 0x65, 0xED, 0xDB,
11+
0x4C, 0x89, 0x33, 0x27>>
1212

1313
test "cast" do
1414
assert Ecto.UUID.cast(@test_uuid) == {:ok, @test_uuid}
@@ -21,6 +21,7 @@ defmodule Ecto.UUIDTest do
2121

2222
test "cast!" do
2323
assert Ecto.UUID.cast!(@test_uuid) == @test_uuid
24+
2425
assert_raise Ecto.CastError, "cannot cast nil to Ecto.UUID", fn ->
2526
assert Ecto.UUID.cast!(nil)
2627
end
@@ -29,6 +30,7 @@ defmodule Ecto.UUIDTest do
2930
test "load" do
3031
assert Ecto.UUID.load(@test_uuid_binary) == {:ok, @test_uuid}
3132
assert Ecto.UUID.load("") == :error
33+
3234
assert_raise ArgumentError, ~r"trying to load string UUID as Ecto.UUID:", fn ->
3335
Ecto.UUID.load(@test_uuid)
3436
end
@@ -59,7 +61,24 @@ defmodule Ecto.UUIDTest do
5961
end
6062
end
6163

62-
test "generate" do
63-
assert << _::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96 >> = Ecto.UUID.generate()
64+
test "generate returns valid uuid_v4" do
65+
assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> = Ecto.UUID.generate()
66+
end
67+
68+
test "generate v4 returns valid uuid_v4" do
69+
assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> =
70+
Ecto.UUID.generate(version: 4)
71+
end
72+
73+
test "generate v7 returns valid uuid_v7" do
74+
assert <<_::64, ?-, _::32, ?-, ?7, _::24, ?-, _::32, ?-, _::96>> =
75+
Ecto.UUID.generate(version: 7)
76+
end
77+
78+
test "generate v7 maintains time-based sortability across milliseconds" do
79+
uuid1 = Ecto.UUID.generate(version: 7)
80+
Process.sleep(1)
81+
uuid2 = Ecto.UUID.generate(version: 7)
82+
assert uuid1 < uuid2
6483
end
6584
end

0 commit comments

Comments
 (0)