Using Ecto Reflection for Simple Admin CRUD Forms in Elixir's Phoenix
Posted on May 7 2024
Table of Contents
If you’re working on a Phoenix project, you probably realized the client might want to view (or even – gasp – edit!) their data in a pretty raw form. Frameworks like Django provide this out of the box. Phoenix, however, leaves this up to you!
Sure, there are ex_admin
and Torch, but ex_admin
is
pretty much abandoned and Torch is more of a form generator. Personally, I’m not
really a fan of code generators. In my opinion, if something can generate code,
why doesn’t it just offer whatever functionality is being provided by the
generated code?
I’ll share what I did and hopefully this helps you!
TL;DR
Leverage your Ecto schemas’ __schema__/1
and
__schema__/2
functions to retrieve the fields and associations
metadata created when using the schema/2
macro. From there, you
can decide how to render an appropriate HTML field in a form. From there, you
can either use Ecto.Changeset.change/2
to handle
the results of an admin user submitting those forms or implement some sort of
protocol that lets you specify an admin-specific changeset.
Getting That Sweet, Sweet Metadata
My first issue was figuring out how to get the metadata that I knew Ecto already
had about my schemas. Y’know, which fields are which types, so that I could use
that metadata to render the appropriate form elements: a text box for
a :string
, a checkbox for a :boolean
, a multiple
select for a many_to_many/3
association,
etc.
After asking in the ever-helpful Elixir Slack, somebody
mentioned that Ecto Schemas supported reflection using
a __schema__
method that could access what I was looking for.
iex(1)> MyApp.Accounts.User.__schema__ :fields
[:id, :email, :full_name, :password_hash, :verified, :inserted_at, :updated_at]
iex(2)> MyApp.Accounts.User.__schema__ :associations
[:roles]
iex(3)> MyApp.Accounts.User.__schema__ :type, :id
:id
iex(4)> MyApp.Accounts.User.__schema__ :type, :email
:string
iex(5)> MyApp.Accounts.User.__schema__ :association, :roles
%Ecto.Association.ManyToMany{
cardinality: :many,
defaults: [],
field: :roles,
join_keys: [user_id: :id, role_id: :id],
join_through: MyApp.Accounts.UserRole,
on_cast: nil,
on_delete: :nothing,
on_replace: :raise,
owner: MyApp.Accounts.User,
owner_key: :id,
queryable: MyApp.Accounts.Role,
related: MyApp.Accounts.Role,
relationship: :child,
unique: false,
where: []
}
iex(6)> "siiiiiiiiiick"
...
Awesome! Using this, I can definitely construct a basic form!
So, we’ll want an admin controller that knows how to add new schema entries generically as well as edit and update existing ones:
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module,
schema: schema,
pk: pk
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
Yep. That’s a lot of code. The first chunk to define the @models
module
attribute is very simple: map a string to a schema module. Easy. If we want, we
could have this map to a tuple like "user" => {Module, "user", "users"}
so we
could control how they’re displayed as well, passing this information to the
view.
The edit/2
method takes a schema type (to map to a schema module via
@models
) and a primary key so we can retrieve the actual entry for that
schema. Generally, this will be a UUID or an integer, but it could be something
more complex, in which case your router.ex
will probably need unusual help to
accommodate this route.
Anyway, edit/2
just grabs the schema module and loads the given entry, using
the reflection we saw earlier to go ahead and preload all the associations.
Neat! We also create a blank changeset and pass these important things on to our
view. new/2
is left as an exercise all for you! But mostly because I haven’t
written it yet myself! I’m also not retrieving the changeset from the session
here. I also leave that up to you.
update/2
works similarly, grabbing the schema module, creating a changeset
from the given data (note the big warning!), and then attempting to update the
repo. Here, we have some reasonable error handling and proper Phoenix CRUD’ing
– or at least… U’ing, but that doesn’t sound as fun.
This gets us really close to being able to update arbitrary schema entries! We just need to setup the view to know how to handle the view logic (duh).
First, though, let’s go to our template and figure out how we want it to work, then we know what helper functions we’ll need in our view.
Oh, by the way, I’m using Slime instead of EEx for my templates. If you haven’t heard of it, you should check it out. It makes for very clean template code.
# edit.html.slime
h1
= "Edit #{String.capitalize(to_string(@schema))}"
= " ID #{@id}"
- action_path = Routes.admin_path(@conn, :update, @schema, @id)
= form_for @changeset, action_path, [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @schema_module, association)
Wow, this is cool! Obviously, most of the magic is going to be in the view functions we now have to implement, but this one view will theoretically handle any basic schema! This is great!
If you’re locking down which fields an admin can modify, you can do something like this:
# edit.html.slime
# for field in fields...
.field
label
.label-text #{String.capitalize(to_string(field))}
= if Enum.member?(@editable_fields, field) do
= field(f, @schema_module, field)
- else
input readonly="true" value=Map.get(@changeset.data, field)
Let’s get to the nitty gritty and implement field/3
and association/3
:
def field(form, schema_module, field, opts \\ []) do
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
:integer -> Phoenix.HTML.Form.number_input(form, field, opts)
# ... I haven't implemented any other types, yet, but datetimes and other
# such fields shouldn't be too difficult!
# see: https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, schema_module, association, opts \\ []) do
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol so they can
# look nice in a select!
# obviously, if you have massive tables, `Repo.all/1` is a bad idea!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
opts = Keyword.put_new(opts, :multiple, true)
Phoenix.HTML.Form.select(form, association, options, opts)
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
Holy cow! Not bad at all! Actually, it would be really easy to extend this and
even handle the wacky edge cases that may come down the line! As previously
mentioned implementing some kind of AdminEditable
protocol would be ideal,
then you could attach whatever metadata you wanted in order for your admin CRUD
system to work exactly how you want.
I’m doing that since I don’t actually want an admin to be able to change certain things that the system is solely responsible for. This includes primary keys, slugs, user-provided information, etc. Therefore this looks a bit different in practice on my end.
# accounts/user.ex
defimpl MyApp.AdminEditable, for: MyApp.Accounts.User do
@readable [:id, :email, :full_name, :inserted_at, :updated_at]
@editable [:verified]
def admin_readable_fields(_s), do: @readable ++ @editable
def admin_editable_fields(_s), do: @editable
# this controls what shows up on the index for a given schema
def admin_index_fields(s), do: admin_readable_fields(s)
end
You could then use these methods instead of schema reflection. You could also handle associations separately as well.
# accounts/user.ex
# defimpl MyApp.AdminEditable ...
@readable_associations []
@editable_associations [:roles]
def admin_readable_associations(_s), do:
@readable_associations ++ @editable_associations
def admin_editable_fields(_s), do: @editable_associations
You could use this and define the implementation for Any
and use the
aforementioned schema reflection and get the best of both worlds!
Anyways, I have a billion ideas on how to extend this basic concept. Hopefully you can implement your own admin interface without writing a form for every single schema you have, too! Ahh, simplicity.
Here’s all the code jumbled together (and perhaps slightly different):
All Together Now!
# router.ex
get("/edit/:schema/:pk", AdminController, :edit)
post("/new/:schema/:pk", AdminController, :create)
put("/update/:schema/:pk", AdminController, :update)
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
# admin_view.ex
def field(form, changeset, schema_module, field, opts \\ []) do
# this is pretty much the result of the magic - render a partial, look at
# additional metadata, etc!
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, changeset, schema_module, association, opts \\ []) do
# similar magic for associations! huzzah!
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
Phoenix.HTML.Form.select(form, association, options, Keyword.put_new(opts, :multiple, true))
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
# new.html.slime is similar and also an exercise for the reader =)
# edit.html.slime
= form_for @changeset, Routes.admin_path(@conn, :update, @schema, @id), [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @changeset, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @changeset, @schema_module, association)