How to replace Ecto.Schema modifications?

So I have my custom ULID implementation. It takes UUID format as well as BASE64 url encoded and transforms it to UUID in database layer. When taking out of DB it's transformed to BASE64 again. It creates shorter URLs which is nice. With ecto I needed just 2 lines of code to have all database IDs in this format. But I don't know what's the best way to do it in ash. Also I've modified timestamps to be utc_datetime_usec instead of naive datetime ones. It's easier to convert them to different timezones that way. Here's the code:
defmodule App.Schema do
defmacro __using__(_env) do
quote do
use Ecto.Schema

@timestamps_opts [
type: :utc_datetime_usec
]
@primary_key {:id, App.Schema.ULID, autogenerate: true}
@foreign_key_type App.Schema.ULID

import Ecto.Changeset
import Ecto.Query
end
end
end
defmodule App.Schema do
defmacro __using__(_env) do
quote do
use Ecto.Schema

@timestamps_opts [
type: :utc_datetime_usec
]
@primary_key {:id, App.Schema.ULID, autogenerate: true}
@foreign_key_type App.Schema.ULID

import Ecto.Changeset
import Ecto.Query
end
end
end
All I needed to do then was to use App.Schema and it was done. Is there a similar way to do it in Ash? Or at least a way to add ULID in this convenient as a separate data type? In case anyone is interested into my ULID implementation here's gist https://gist.github.com/lamp-town-guy/306694cdb510bd93894c51bb1aaf917e
Gist
ULID Elixir
ULID Elixir. GitHub Gist: instantly share code, notes, and snippets.
9 Replies
ZachDaniel
ZachDanielβ€’3y ago
You can do
defmodule App.Resource do
defmacro __using__(opts) do
quote do
use Ash.Resource, unquote(opts)

attributes do
attribute :id, App.Schema.ULID, generated?: true, default: &App.Schema.ULID.generate/1
timestamps type: :utc_datetime_usec)
end
end
end
end
defmodule App.Resource do
defmacro __using__(opts) do
quote do
use Ash.Resource, unquote(opts)

attributes do
attribute :id, App.Schema.ULID, generated?: true, default: &App.Schema.ULID.generate/1
timestamps type: :utc_datetime_usec)
end
end
end
end
for most of it For the default foreign key type, that is actually a global config currenlty
config :ash, :default_belongs_to_type, App.Schema.ULID
config :ash, :default_belongs_to_type, App.Schema.ULID
You'll need to write an Ash.Type for ULID, although other users here can likely help with that @kernel
kernel
kernelβ€’3y ago
πŸ‘‹πŸΏ
defmodule App.Type.ULID do
use Ash.Type

@impl Ash.Type
def storage_type, do: Ecto.ULID.type()

@impl Ash.Type
def cast_input(value, _) when is_binary(value) do
Ecto.Type.cast(Ecto.ULID, String.trim(value))
end

def cast_input(value, _) do
Ecto.Type.cast(Ecto.ULID, value)
end

@impl Ash.Type
def cast_stored(value, constraints) do
case Ecto.Type.load(Ecto.ULID, value) do
:error ->
cast_input(value, constraints)

{:ok, value} ->
{:ok, value}
end
rescue
_e in ArgumentError ->
cast_input(value, constraints)
end

@impl Ash.Type
def dump_to_embedded(value, constraints) do
cast_input(value, constraints)
end

@impl Ash.Type
def dump_to_native(value, _) do
Ecto.Type.dump(Ecto.ULID, value)
end

def generate(timestamp \\ System.system_time(:millisecond)) do
timestamp
|> Ecto.ULID.bingenerate()
|> Ecto.UUID.load!()
end
end
defmodule App.Type.ULID do
use Ash.Type

@impl Ash.Type
def storage_type, do: Ecto.ULID.type()

@impl Ash.Type
def cast_input(value, _) when is_binary(value) do
Ecto.Type.cast(Ecto.ULID, String.trim(value))
end

def cast_input(value, _) do
Ecto.Type.cast(Ecto.ULID, value)
end

@impl Ash.Type
def cast_stored(value, constraints) do
case Ecto.Type.load(Ecto.ULID, value) do
:error ->
cast_input(value, constraints)

{:ok, value} ->
{:ok, value}
end
rescue
_e in ArgumentError ->
cast_input(value, constraints)
end

@impl Ash.Type
def dump_to_embedded(value, constraints) do
cast_input(value, constraints)
end

@impl Ash.Type
def dump_to_native(value, _) do
Ecto.Type.dump(Ecto.ULID, value)
end

def generate(timestamp \\ System.system_time(:millisecond)) do
timestamp
|> Ecto.ULID.bingenerate()
|> Ecto.UUID.load!()
end
end
i.e:
lamp-town-guy
lamp-town-guyOPβ€’3y ago
Thanks @kernel but in the end I've copied UUID and changed required parts. @Zach Daniel your snippet was useful but there were few things missing. Primary key and allow nil. I have ecto and ash types defined in different files which I might merge together.
attribute :id, App.Type.AshULID,
generated?: true,
default: &App.Type.EctoULID.generate/0,
primary_key?: true,
allow_nil?: false
attribute :id, App.Type.AshULID,
generated?: true,
default: &App.Type.EctoULID.generate/0,
primary_key?: true,
allow_nil?: false
But now when I try to create new post in Phoenix example I get this error:
[debug] HANDLE EVENT "create_post" in AppWeb.ExampleLiveView
Parameters: %{"" => "", "form" => %{"title" => "test"}}
[debug] QUERY OK source="posts" db=0.1ms idle=1995.5ms
SELECT p0."id", p0."inserted_at", p0."updated_at", p0."title", p0."content" FROM "posts" AS p0 []
↳ AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:613
[debug] HANDLE EVENT "create_post" in AppWeb.ExampleLiveView
Parameters: %{"" => "", "form" => %{"title" => "test"}}
[debug] QUERY OK source="posts" db=0.1ms idle=1995.5ms
SELECT p0."id", p0."inserted_at", p0."updated_at", p0."title", p0."content" FROM "posts" AS p0 []
↳ AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:613
How do I get to more verbose error message?
ZachDaniel
ZachDanielβ€’3y ago
I don’t see an error there. Is that all the logs you have?
lamp-town-guy
lamp-town-guyOPβ€’3y ago
Well, that's all I get when I want to create a blog post. I have nothing in the database so it should be created.
ZachDaniel
ZachDanielβ€’3y ago
πŸ€” are you rendering errors in your form in any way? In your error branch of submitting the form, try inspecting the result of this: AshPhoenix.Form.errors(form, for_path: :all) Might shed some light on what is going on
lamp-town-guy
lamp-town-guyOPβ€’3y ago
Well, I might contribute to phoenix guide because there are no error messages in the blog example. I expected it to be there. Default value is not being generated correctly.
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :id,
message: "is invalid",
private_vars: nil,
value: &App.Type.EctoULID.generate/1,
changeset: nil,
query: nil,
error_context: [],
vars: [field: :id, message: "is invalid"],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :id,
message: "is invalid",
private_vars: nil,
value: &App.Type.EctoULID.generate/1,
changeset: nil,
query: nil,
error_context: [],
vars: [field: :id, message: "is invalid"],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
ZachDaniel
ZachDanielβ€’3y ago
Yeah, so since id isn't part of your form, and I assume there is no corresponding error tag What some people will do is something like this:
if @form.source.just_submitted? do # or just @form.just_submitted? pre 1.7
Please address the errors with your form submission.

for {path, errors} <- AshPhoenix.Form.errors(form, for_path: :all) do
for {field, error} <- errors do
<%= Enum.join(path ++ [field], ".") %>: <%= error %>
end
end
end
if @form.source.just_submitted? do # or just @form.just_submitted? pre 1.7
Please address the errors with your form submission.

for {path, errors} <- AshPhoenix.Form.errors(form, for_path: :all) do
for {field, error} <- errors do
<%= Enum.join(path ++ [field], ".") %>: <%= error %>
end
end
end
lamp-town-guy
lamp-town-guyOPβ€’3y ago
OK I feel super dumb. default: &App.Type.EctoULID.generate/1 was supposed to be default: &App.Type.EctoULID.generate/0. Thanks, it's working now.

Did you find this page helpful?