Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/ecto/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ defmodule Ecto.Schema do

* `:autogenerate` - a `{module, function, args}` tuple for a function
to call to generate the field value before insertion if value is not set.
A list of options is passed as first argument `{type, :autogenerate, [options]}`.
A shorthand value of `true` is equivalent to `{type, :autogenerate, []}`.

* `:read_after_writes` - When true, the field is always read back
Expand Down Expand Up @@ -1594,6 +1595,8 @@ defmodule Ecto.Schema do
as `@primary_key` (see the [Schema attributes](#module-schema-attributes)
section for more info). Primary keys are automatically set up for embedded
schemas as well, defaulting to `{:id, :binary_id, autogenerate: true}`.
This will generate the default UUID v4. You can use UUID v7 instead by setting
the primary key to `{:id, :binary_id, autogenerate: [version: 7]}`
Note `:primary_key`s are not automatically read back on `insert/2`,
unless one of `autogenerate: true` or `read_after_writes: true` is set.

Expand Down Expand Up @@ -2042,6 +2045,9 @@ defmodule Ecto.Schema do
{_, _, _} ->
store_mfa_autogenerate!(mod, name, type, gen)

autogenerate_opts when is_list(autogenerate_opts) ->
store_mfa_autogenerate!(mod, name, type, {type, :autogenerate, [autogenerate_opts]})

true ->
store_type_autogenerate!(mod, name, source || name, type, pk?)

Expand Down
50 changes: 44 additions & 6 deletions lib/ecto/uuid.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
defmodule Ecto.UUID do
@moduledoc """
An Ecto type for UUID strings.

## Autogeneration

This type can be used for any UUID field in your schemas.
It is used when autogenerating binary IDs in Ecto.Schema.
By default, autogenerated UUIDs use version 4 (random):

use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}

To use UUID v7 (time-ordered) instead:

use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: [version: 7]}
"""

use Ecto.Type
Expand All @@ -15,6 +29,11 @@ defmodule Ecto.UUID do
"""
@type raw :: <<_::128>>

@typedoc """
currently supported option is version, it accepts 4 or 7.
"""
@type options :: [version: 4 | 7]

@doc false
def type, do: :uuid

Expand Down Expand Up @@ -47,6 +66,7 @@ defmodule Ecto.UUID do
"""
@spec cast(t | raw | any) :: {:ok, t} | :error
def cast(uuid)

def cast(
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2, d3,
d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
Expand Down Expand Up @@ -105,6 +125,7 @@ defmodule Ecto.UUID do
"""
@spec dump(uuid_string :: t | any) :: {:ok, raw} | :error
def dump(uuid_string)

def dump(
<<a1, a2, a3, a4, a5, a6, a7, a8, ?-, b1, b2, b3, b4, ?-, c1, c2, c3, c4, ?-, d1, d2, d3,
d4, ?-, e1, e2, e3, e4, e5, e6, e7, e8, e9, e10, e11, e12>>
Expand Down Expand Up @@ -183,24 +204,41 @@ defmodule Ecto.UUID do
end
end

@default_version 4
@doc """
Generates a random, version 4 UUID.
Generates a uuid with the given options.
"""
@spec generate() :: t
def generate(), do: encode(bingenerate())
@spec generate(options) :: t
def generate(opts \\ []), do: encode(bingenerate(opts))

@doc """
Generates a random, version 4 UUID in the binary format.
Generates a uuid with the given options in binary format.
"""
@spec bingenerate() :: raw
def bingenerate() do
@spec bingenerate(options) :: raw
def bingenerate(opts \\ []) do
case Keyword.get(opts, :version, @default_version) do
4 -> bingenerate_v4()
7 -> bingenerate_v7()
version -> raise ArgumentError, "unknown UUID version: #{inspect(version)}"
end
end

defp bingenerate_v4 do
<<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16)
<<u0::48, 4::4, u1::12, 2::2, u2::62>>
end

defp bingenerate_v7 do
milliseconds = System.system_time(:millisecond)
<<u0::12, u1::62, _::6>> = :crypto.strong_rand_bytes(10)

<<milliseconds::48, 7::4, u0::12, 2::2, u1::62>>
end

# Callback invoked by autogenerate fields.
@doc false
def autogenerate, do: generate()
def autogenerate(opts \\ []), do: generate(opts)

@spec encode(raw) :: t
defp encode(
Expand Down
38 changes: 30 additions & 8 deletions test/ecto/repo/autogenerate_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ defmodule Ecto.Repo.AutogenerateTest do

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

def dump(nil, _, _), do: {:ok, nil}

def dump(data, _, %{prefix: _prefix}),
do: {:ok, data |> String.split(@separator) |> List.last() |> Integer.parse()}
end
Expand Down Expand Up @@ -160,6 +163,22 @@ defmodule Ecto.Repo.AutogenerateTest do
assert byte_size(code_uuid) == 36
end

test "autogenerates uuid v4 and v7 values" do
schema = TestRepo.insert!(%Company{})
assert byte_size(schema.uuid_v4) == 36
assert byte_size(schema.uuid_v7) == 36

changeset = Ecto.Changeset.cast(%Company{}, %{}, [])
schema = TestRepo.insert!(changeset)
assert byte_size(schema.uuid_v4) == 36
assert byte_size(schema.uuid_v7) == 36

changeset = Ecto.Changeset.cast(%Company{}, %{uuid_v4: nil, uuid_v7: nil}, [])
schema = TestRepo.insert!(changeset)
assert byte_size(schema.uuid_v4) == 36
assert byte_size(schema.uuid_v7) == 36
end

## Timestamps

test "sets inserted_at and updated_at values" do
Expand Down Expand Up @@ -193,24 +212,27 @@ defmodule Ecto.Repo.AutogenerateTest do
end

test "does not update updated_at when the associated record did not change" do
company = TestRepo.insert!(%Company{offices: [%Office{id: 1, name: "1"}, %Office{id: 2, name: "2"}]})
company =
TestRepo.insert!(%Company{offices: [%Office{id: 1, name: "1"}, %Office{id: 2, name: "2"}]})

[office_one, office_two] = company.offices

changes = %{offices: [%{id: 1, name: "updated"}, %{id: 2, name: "2"}]}

updated_company =
company
|> Ecto.Changeset.cast(changes, [])
|> Ecto.Changeset.cast_assoc(:offices)
|> TestRepo.update!()

[updated_office_one, updated_office_two] = updated_company.offices
assert updated_office_one.updated_at != office_one.updated_at
assert updated_office_two.updated_at == office_two.updated_at
end

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

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

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

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

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

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

default = TestRepo.update!(%UtcUsecMod{id: 1} |> Ecto.Changeset.change, force: true)
default = TestRepo.update!(%UtcUsecMod{id: 1} |> Ecto.Changeset.change(), force: true)
refute default.inserted_at
assert %DateTime{time_zone: "Etc/UTC", microsecond: {_, 6}} = default.updated_at
end
Expand Down
27 changes: 23 additions & 4 deletions test/ecto/uuid_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ defmodule Ecto.UUIDTest do
@test_uuid_invalid_characters "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
@test_uuid_invalid_format "xxxxxxxx-xxxx"
@test_uuid_null "00000000-0000-0000-0000-000000000000"
@test_uuid_binary <<0x60, 0x1D, 0x74, 0xE4, 0xA8, 0xD3, 0x4B, 0x6E,
0x83, 0x65, 0xED, 0xDB, 0x4C, 0x89, 0x33, 0x27>>
@test_uuid_binary <<0x60, 0x1D, 0x74, 0xE4, 0xA8, 0xD3, 0x4B, 0x6E, 0x83, 0x65, 0xED, 0xDB,
0x4C, 0x89, 0x33, 0x27>>

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

test "cast!" do
assert Ecto.UUID.cast!(@test_uuid) == @test_uuid

assert_raise Ecto.CastError, "cannot cast nil to Ecto.UUID", fn ->
assert Ecto.UUID.cast!(nil)
end
Expand All @@ -29,6 +30,7 @@ defmodule Ecto.UUIDTest do
test "load" do
assert Ecto.UUID.load(@test_uuid_binary) == {:ok, @test_uuid}
assert Ecto.UUID.load("") == :error

assert_raise ArgumentError, ~r"trying to load string UUID as Ecto.UUID:", fn ->
Ecto.UUID.load(@test_uuid)
end
Expand Down Expand Up @@ -59,7 +61,24 @@ defmodule Ecto.UUIDTest do
end
end

test "generate" do
assert << _::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96 >> = Ecto.UUID.generate()
test "generate returns valid uuid_v4" do
assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> = Ecto.UUID.generate()
end

test "generate v4 returns valid uuid_v4" do
assert <<_::64, ?-, _::32, ?-, ?4, _::24, ?-, _::32, ?-, _::96>> =
Ecto.UUID.generate(version: 4)
end

test "generate v7 returns valid uuid_v7" do
assert <<_::64, ?-, _::32, ?-, ?7, _::24, ?-, _::32, ?-, _::96>> =
Ecto.UUID.generate(version: 7)
end

test "generate v7 maintains time-based sortability across milliseconds" do
uuid1 = Ecto.UUID.generate(version: 7)
Process.sleep(1)
uuid2 = Ecto.UUID.generate(version: 7)
assert uuid1 < uuid2
end
end
Loading