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
13 changes: 9 additions & 4 deletions src/kura_repo_worker.erl
Original file line number Diff line number Diff line change
Expand Up @@ -826,10 +826,15 @@ maybe_generate_pk(SchemaMod, Changes) ->
true ->
Changes;
false ->
Types = kura_schema:field_types(SchemaMod),
case maps:get(PK, Types, undefined) of
uuid -> Changes#{PK => generate_uuid_v4()};
_ -> Changes
case kura_schema:generate_id(SchemaMod) of
{ok, Id} ->
Changes#{PK => Id};
undefined ->
Types = kura_schema:field_types(SchemaMod),
case maps:get(PK, Types, undefined) of
uuid -> Changes#{PK => generate_uuid_v4()};
_ -> Changes
end
end
end.

Expand Down
17 changes: 17 additions & 0 deletions src/kura_schema.erl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fields() ->
embed/2,
constraints/1,
indexes/1,
generate_id/1,
run_before_insert/2,
run_after_insert/2,
run_before_update/2,
Expand All @@ -52,6 +53,7 @@ fields() ->
embeds/0,
constraints/0,
indexes/0,
generate_id/0,
before_insert/1,
after_insert/1,
before_update/1,
Expand All @@ -66,6 +68,8 @@ fields() ->
-callback constraints() -> [kura_migration:table_constraint()].
-callback indexes() -> [kura_migration:index_def()].

-callback generate_id() -> term().

-callback before_insert(#kura_changeset{}) -> {ok, #kura_changeset{}} | {error, #kura_changeset{}}.
-callback after_insert(map()) -> {ok, map()} | {error, term()}.
-callback before_update(#kura_changeset{}) -> {ok, #kura_changeset{}} | {error, #kura_changeset{}}.
Expand Down Expand Up @@ -214,6 +218,19 @@ embed_column_map([], Acc) ->
embed_column_map([E | Rest], Acc) ->
embed_column_map(Rest, Acc#{E#kura_embed.name => atom_to_binary(E#kura_embed.name, utf8)}).

%%----------------------------------------------------------------------
%% ID generation
%%----------------------------------------------------------------------

-doc "Generate a primary key value if the schema defines `generate_id/0`.".
-spec generate_id(module()) -> {ok, term()} | undefined.
generate_id(Mod) ->
_ = code:ensure_loaded(Mod),
case erlang:function_exported(Mod, generate_id, 0) of
true -> {ok, Mod:generate_id()};
false -> undefined
end.

%%----------------------------------------------------------------------
%% Lifecycle hooks
%%----------------------------------------------------------------------
Expand Down
113 changes: 113 additions & 0 deletions test/kura_generate_id_tests.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
-module(kura_generate_id_tests).

-include_lib("eunit/include/eunit.hrl").
-include("kura.hrl").

%%----------------------------------------------------------------------
%% Test fixture
%%----------------------------------------------------------------------

generate_id_test_() ->
{setup, fun setup/0, fun teardown/1, fun(_) ->
[
{"generate_id callback is used on insert", fun t_insert_uses_generate_id/0},
{"explicit id takes precedence over generate_id", fun t_explicit_id_wins/0},
{"each insert gets a unique generated id", fun t_unique_ids/0},
{"schema without generate_id still works", fun t_schema_without_callback/0}
]
end}.

setup() ->
application:ensure_all_started(pgo),
application:ensure_all_started(kura),
kura_test_repo:start(),
{ok, _} = kura_test_repo:query(
"CREATE TABLE IF NOT EXISTS generate_id_items ("
" id UUID PRIMARY KEY,"
" name VARCHAR(255) NOT NULL,"
" inserted_at TIMESTAMPTZ,"
" updated_at TIMESTAMPTZ"
")",
[]
),
{ok, _} = kura_test_repo:query(
"CREATE TABLE IF NOT EXISTS users ("
" id BIGSERIAL PRIMARY KEY,"
" name VARCHAR(255) NOT NULL,"
" email VARCHAR(255) NOT NULL,"
" age INTEGER,"
" active BOOLEAN DEFAULT true,"
" role VARCHAR(255) DEFAULT 'user',"
" score DOUBLE PRECISION,"
" metadata JSONB,"
" status VARCHAR(255),"
" tags TEXT[],"
" lock_version INTEGER DEFAULT 0,"
" inserted_at TIMESTAMPTZ,"
" updated_at TIMESTAMPTZ"
")",
[]
),
ok.

teardown(_) ->
kura_test_repo:query("DROP TABLE IF EXISTS generate_id_items CASCADE", []),
kura_test_repo:query("DROP TABLE IF EXISTS users CASCADE", []),
ok.

%%----------------------------------------------------------------------
%% Tests
%%----------------------------------------------------------------------

t_insert_uses_generate_id() ->
CS = kura_changeset:cast(
kura_test_generate_id_schema,
#{},
#{~"name" => ~"Alice"},
[name]
),
{ok, Record} = kura_test_repo:insert(CS),
Id = maps:get(id, Record),
?assertNotEqual(undefined, Id),
%% Verify it's a valid UUID v7 (version nibble = 7)
<<_:14/binary, Version:1/binary, _/binary>> = Id,
?assertEqual(~"7", Version).

t_explicit_id_wins() ->
ExplicitId = ~"00000000-0000-7000-8000-000000000001",
CS = kura_changeset:cast(
kura_test_generate_id_schema,
#{},
#{~"id" => ExplicitId, ~"name" => ~"Bob"},
[id, name]
),
{ok, Record} = kura_test_repo:insert(CS),
?assertEqual(ExplicitId, maps:get(id, Record)).

t_unique_ids() ->
Insert = fun(Name) ->
CS = kura_changeset:cast(
kura_test_generate_id_schema,
#{},
#{~"name" => Name},
[name]
),
{ok, Record} = kura_test_repo:insert(CS),
maps:get(id, Record)
end,
Id1 = Insert(~"One"),
Id2 = Insert(~"Two"),
Id3 = Insert(~"Three"),
?assertNotEqual(Id1, Id2),
?assertNotEqual(Id2, Id3),
?assertNotEqual(Id1, Id3).

t_schema_without_callback() ->
CS = kura_changeset:cast(
kura_test_schema,
#{},
#{~"name" => ~"NoCallback", ~"email" => ~"no@cb.com"},
[name, email]
),
{ok, Record} = kura_test_repo:insert(CS),
?assert(is_integer(maps:get(id, Record))).
23 changes: 23 additions & 0 deletions test/kura_test_generate_id_schema.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-module(kura_test_generate_id_schema).
-behaviour(kura_schema).

-include("kura.hrl").

-export([table/0, fields/0, generate_id/0]).

table() -> ~"generate_id_items".

fields() ->
[
#kura_field{name = id, type = uuid, primary_key = true, nullable = false},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = inserted_at, type = utc_datetime},
#kura_field{name = updated_at, type = utc_datetime}
].

generate_id() ->
<<A:48, _:4, B:12, _:2, C:62>> = crypto:strong_rand_bytes(16),
Bytes = <<A:48, 7:4, B:12, 2:2, C:62>>,
Hex = binary:encode_hex(Bytes, lowercase),
<<P1:8/binary, P2:4/binary, P3:4/binary, P4:4/binary, P5:12/binary>> = Hex,
<<P1/binary, "-", P2/binary, "-", P3/binary, "-", P4/binary, "-", P5/binary>>.
Loading