Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Weird uses of ecto embedded and schemaless

weird-uses-of-ecto-embedded-and-schemaless.livemd

Weird uses of ecto embedded and schemaless

Mix.install([
  {:ecto, "3.10.3"}
])

Section

Embedded schemas are generally used for jsonb columns

However, they can also be used for much more such as params scrubbing and prevalidation.

defmodule Login do
  use Ecto.Schema

  alias Ecto.Changeset

  @primary_key false
  embedded_schema do
    field(:email, :string)
    field(:password, :string)
    field(:remember_me, :boolean)
    field(:age, :integer)
  end

  defp traverse_errors(%Changeset{} = changeset) do
    Changeset.traverse_errors(changeset, fn {msg, opts} ->
      Enum.reduce(opts, msg, fn {key, value}, acc ->
        String.replace(acc, "%{#{key}}", to_string(value))
      end)
    end)
  end

  def scrub_params(%{} = params) do
    case Changeset.cast(%__MODULE__{}, params, [:email, :password, :remember_me, :age]) do
      %{valid?: true} = c -> {:ok, Changeset.apply_changes(c)}
      %{valid?: false} = c -> {:error, traverse_errors(c)}
    end
  end
end

We can use embedded schemas to take api params and clean them up a bit

[
  Login.scrub_params(%{"email" => "foo@example.com"}),
  Login.scrub_params(%{"email" => "foo@example.com", "password" => "pass1"}),
  Login.scrub_params(%{"email" => "foo@example.com", "password" => 123}),
  Login.scrub_params(%{
    "email" => "foo@example.com",
    "password" => "pass1",
    "remember_me" => "invalid"
  })
]

It automatically ensures correct types are passed in. It can even coerce strings to numbers, for example

Login.scrub_params(%{"age" => "55"})

We can also add all the basic validation without needing to go into the db and actual schema records.

alias Ecto.Changeset

%Login{}
|> Changeset.cast(
  %{
    "email" => nil,
    "password" => 123,
    "remember_me" => false,
    "age" => 17
  },
  [:email, :password, :remember_me, :age]
)
|> Changeset.validate_required([:email, :password, :age])
|> Changeset.validate_acceptance(:remember_me)
|> Changeset.validate_number(:age, greater_than_or_equal_to: 18)

This is great for validating complex forms that might not necessarily have a backing db record. Something to kick start a long running process, for example

But this means a huge number of schemas!

Sure, but we can reduce that down by using schemaless changesets

https://hexdocs.pm/ecto/Ecto.Changeset.html#module-schemaless-changesets

defaults = %{username: nil, password: nil, tos_accepted: nil}
types = %{username: :string, password: :string, tos_accepted: :boolean}
params = %{username: "joe", password: nil, tos_accepted: false}
fields = Map.keys(types)

{defaults, types}
|> Changeset.cast(params, fields)
|> Changeset.validate_required([:username, :password])
|> Changeset.validate_acceptance(:tos_accepted)

Do we need this for every API?

Not really. If se use Open API, then it kind of does that for us, though potentially not fully.

Is TypedStruct an alternative?

TypedStruct does not provide any extra features we don’t already have in elixir. It’s syntactic sugar to define a struct (with enforced keys) and a typespec for it in one macro, one call, nothing more.

It will not provide any validation capabilities that ecto changesets do for us.

You get a bit less boiler plate at the cost of less clarity (it’s not core, less known) and at the cost of the scrub/validation feature and adding an extra dependency (you probably already have ecto as a dependency).

If you really care about reducing boiler plate and are using it to enforce strictness in code, it’s a fine option.

If you are cleaning external input in any way and it’s not being handled by something like Open API schemas, I would go with Ecto.