Using Model Validations with Phoenix

Previously I wrote about using ecto callbacks on a model in phoenix. I used the callback to populate a slug field on the post model if it was left blank.

Since the slug field will be used to find a post we have to ensure that it is unique. Enter validations. Ecto comes with a number of validations and also includes the one we need validate_unique. Which accepts a changeset, field, and a Repo. I aliased my projects repo alias BirdBlog.Repo so I could just call Repo.

 def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_unique(:slug, on: Repo)
  end

I thought this would take care of everything and I was all done ensuring that the slugs would be unique. I was wrong. The slugs that were being generated by the before_insert callback were still able to be duplicates. Looking at Ecto's documentation on callbacks I found out that callbacks could not be aborted and they were ran AFTER validations. So I was left to the task of making sure the slugs I generated were unique.

I decided to generate a standard slug off of the title as before, check the post repo if I could find any posts using that slug, if so then recursively try again tacking a random 5 digits onto the end of the slug.

def unique_slug({_, title}) do  
    title = title_to_slug(title)
    exists = Repo.get_by(BirdBlog.Post, slug: title)
    if exists do
      unique_slug({:error, "#{title}-#{:random.uniform(99999)}"})
    else
      title
    end
  end

Full model is below. I feel like there is probably a better way to do all this, but I've learned a lot exploring validations this way.

defmodule BirdBlog.Post do  
  use BirdBlog.Web, :model
  alias BirdBlog.Repo

  before_insert :generate_slug

  schema "posts" do
    field :title, :string
    field :slug, :string
    field :body, :string

    timestamps
  end

  @required_fields ~w(title body)
  @optional_fields ~w(slug)

  @doc """
  Creates a changeset based on the `model` and `params`.
  If no params are provided, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_unique(:slug, on: Repo)
  end

  def title_to_slug(title) do
    title
    |> String.downcase
    |> String.replace(" ", "-")
  end

  def unique_slug({_, title}) do
    title = title_to_slug(title)
    exists = Repo.get_by(BirdBlog.Post, slug: title)
    if exists do
      unique_slug({:error, "#{title}-#{:random.uniform(99999)}"})
    else
      title
    end
  end

  defp generate_slug(changeset) do
    case fetch_field(changeset, :slug) do
      {:changes, nil} ->
        slug = unique_slug(fetch_field(changeset, :title))
        changeset = put_change(changeset, :slug, slug)
      _ ->
        changeset
     end
  end

end