Differences between UUID primary keys in Ecto vs Ash

As far as I can tell, the Ash Resource special attribute uuid_primary_key unwraps into:
attribute :id, :uuid do
writeable? false
default &Ash.UUID.generate/0
primary_key? true
allow_nil? false
end
attribute :id, :uuid do
writeable? false
default &Ash.UUID.generate/0
primary_key? true
allow_nil? false
end
Ash.UUID.generate/0 seems to be a passthrough to Ecto.UUID.generate(). What I found unexpected was the migration files. Echo's migration file looks like:
...
def change do
create table(:posts, primary_key: false) do
add :id, :binary_id, primary_key: true
...
end
end
...
def change do
create table(:posts, primary_key: false) do
add :id, :binary_id, primary_key: true
...
end
end
Ash's migration file looks like:
def up do
create table(:posts, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
...
end
end
def up do
create table(:posts, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
...
end
end
I'm not entirely sure if the functionality will all be the same at the end of the day. Reading docs and source code, it seems like Postgrex treats :binary, :binary_id, and :uuid similarly when ran by Ecto SQL, but I'm not sure. The most surprising is the :default option in Ash's implementation. It hard-codes a Postgres DB function as a default value even though :primary_key option is set (which I think inherently makes the field non-nullable in most databases. The reason I'm worried about it is because I'm interested in using UUIDv7 for my PKs. That function may never run if generating UUIDs in Elixir-land, but it seems off to be there.
15 Replies
ZachDaniel
ZachDaniel2y ago
I’m pretty sure that using uuid and binary types under the hood will be equivalent in all ways that matter 🙂 in terms of the default, that is there because uuid_primary_key sets a default value If you want to change it, you can set default: &Some.other_generator/0 If the migration generator sees default functions that it knows about it sets a db level default. It’s a bit magical but covers the most common cases of DateTime.utc_now and Ash.UUID.generate/0 You can configure the migration defaults for a field using the ash_postgres DSL:
postgres do

migration_defaults id:fragment(\”generate a uuid 7\”)”
end
postgres do

migration_defaults id:fragment(\”generate a uuid 7\”)”
end
You also don’t have to use uuid_primary_key you could define a uuidv7 type and say
attribute :id, UUIDv7, primary_key?: true, generated?: true, writable?: false, default: `UUIDv7.generate/0`
attribute :id, UUIDv7, primary_key?: true, generated?: true, writable?: false, default: `UUIDv7.generate/0`
Which should be the equivalent of uuid_primary_key but with your own type.
Korbin
KorbinOP2y ago
Thanks for the info! I'm not quite a pro at navigating all the docs and source in Ash yet. Yeah, I've been playing with manually defining the PK via attribute DSL. Something I noticed is in the documentation online, they say that using uuid_primary_key is equivalent to what I wrote in my original post, but that might not be true. I noticed a file at resource_snapshots/repo/posts/yada-yada.json tells a different story. It says that generated? is false. Did I catch that right, or am I mistaken?
ZachDaniel
ZachDaniel2y ago
🤔 Ah, yeah looks like you're right generated? is primarily informational, and is actually only important with using integer primary keys with postgres (the generated? tells it to use a series)
Korbin
KorbinOP2y ago
What property tells it to generate ID in Elixir-land if not that one?
ZachDaniel
ZachDaniel2y ago
the default being &Ash.UUID.generate/0 generated?: true means "the database may supply a value for this" IIRC we do read_after_writes? in ecto for things that are marked as generated though, but that hasn't seemed to impact anything. Would need to test it out by adding default: nil to uuid_primary_key and seeing if it comes back nil. If so we may want to update uuid_primary_key to have generated?: true
Korbin
KorbinOP2y ago
Okay, dropping PK scenario. What of these statements: generated false and default &MyMod.my_func/0 => value generated in Elixir-land generated true and default fragement("plugin_func()") => value generated in data-layer land (Postgres)
ZachDaniel
ZachDaniel2y ago
So default in the attribute is only concerning elixirland If you want to set a database default you do it in the postgres block
postgres do
...
migration_defaults [....]
end
postgres do
...
migration_defaults [....]
end
generated? false means "the data layer will never supply a value for this. Its only the one we set" generated? true means it might, and so we make sure to read it after writes and do any other required things (keep in mind Ash is data layer agnostic, so not all options make tangible differences on all data layers)
Korbin
KorbinOP2y ago
I see. Okay, I think that clears it up for me. My main confusion I think is seeing that fragment in the migrations. Seems to show up if type is :uuid no matter what.
ZachDaniel
ZachDaniel2y ago
It should only show up if default: &Ash.UUID.generate/0 (the default for uuid_primary_key
Korbin
KorbinOP2y ago
I swapped it for Ecto's function to see what'd it do and it still threw that on there. I'm testing writing my own to see what it does
ZachDaniel
ZachDaniel2y ago
oh, yeah it might also have a case for Ecto.UUID.generate/0 😆 lemme check
@uuid_functions [&Ash.UUID.generate/0, &Ecto.UUID.generate/0]

defp default(%{name: name, default: default}, resource, repo) when is_function(default) do
configured_default(resource, name) ||
cond do
default in @uuid_functions && "uuid-ossp" in (repo.config()[:installed_extensions] || []) ->
~S[fragment("uuid_generate_v4()")]

default == (&DateTime.utc_now/0) ->
~S[fragment("now()")]

true ->
"nil"
end
end
@uuid_functions [&Ash.UUID.generate/0, &Ecto.UUID.generate/0]

defp default(%{name: name, default: default}, resource, repo) when is_function(default) do
configured_default(resource, name) ||
cond do
default in @uuid_functions && "uuid-ossp" in (repo.config()[:installed_extensions] || []) ->
~S[fragment("uuid_generate_v4()")]

default == (&DateTime.utc_now/0) ->
~S[fragment("now()")]

true ->
"nil"
end
end
Korbin
KorbinOP2y ago
Haha, there it is :thinkies: Okay, I will close this post soon. I want to read over it again and comment a summary before I do. Okay, following up here and for posterity: more 2 questions. Question 1: your example @Zach Daniel:
attribute :id, UUIDv7, primary_key?: true, generated?: true, writable?: false, default: `UUIDv7.generate/0`
attribute :id, UUIDv7, primary_key?: true, generated?: true, writable?: false, default: `UUIDv7.generate/0`
The type (in this theoretical case "UUIDv7") concerns itself with decoding and encoding. The default concerns itself with generating a value and encoding to put into the database in Elixir-land for create actions. In this case, generated? should be false, right? Question 2: for UUIDs that we generate in Elixir-land, as is the case with default: &Ash.UUID.generate/0, what's the point of that magical ~S[fragment("uuid_generate_v4()")]?
ZachDaniel
ZachDaniel2y ago
1. Correct generated? only matters if you don't have a default, and set migration_defaults [id: <a fragment>] or for integer_primary_key 2. It keeps your data more consistent with other usage of it. I.e if someone goes in and creates a thing in the db directly they don't have to figure out how to make a valid uuid
Terris
Terris2y ago
Creating a uuidv7 type and checking if the https://pgxn.org/dist/pg_uuidv7/ extension is installed (otherwise use v4) is on my todo list.
PGXN: PostgreSQL Extension Network
pg_uuidv7: Create UUIDv7 values in Postgres / PostgreSQL Extension ...
Search all indexed extensions, distributions, users, and tags on the PostgreSQL Extension Network.
Korbin
KorbinOP2y ago
TLDR; Don't worry about it if you're okay with your UUID being version 4. The end-of-the day for most projects will result in the same fuctionality. If you want to use a different UUID version for your PKs, manually write out an attribute DSL block and put your UUID-generating function capture in default. To sum up this post, my confusion around UUIDs in Ecto vs Ash came from comparing the migration files respectively. - Ecto uses type binary_id, Ash uses uuid; I have not verified this is exactly the same inside the DB - Ash adds the null: false bit when using uuid_primary_key - Ash adds an Ecto fragment for generating UUIDs on the DB even though Ash generates them in Elixir-land (as in the DB doesn't do it when using Ash); I haven't varified and wouldn't know how to - Ash's addition of the Ecto fragment only applies when when the default in the attribute DSL block is Ash's or Ecto's standard UUID generate function If wanting to use a different type of UUID, you cannot use uuid_primary_key at the time of writing. You will have to manually write out an attribute DSL block and use default (this applies to Elixir-land only) for generating the UUID primary key. If you'd like parity to Ash's magic fragment, you'll need to use the migration_defaults DSL in a postgres clause to do it. Don't know if I got the verbiage right.

Did you find this page helpful?