Skip to Content

lyte.dev

about blog contact

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)