1

I'd like to have a unique field in an Ecto model. This field should contain a random string which I can generate easily (for example, see here). However, I would like to avoid to generate the string and check if it's already present in the database, as that would expose me to race conditions.

I'd like to have it retry insertion until a unique string is found. But how do I it? Should it be inside the changeset/2 function?

defmodule LetsPlan.Event do
  use LetsPlan.Web, :model

  schema "events" do
    field :name, :string
    field :from, Ecto.DateTime
    field :to, Ecto.DateTime
    field :slug, :string

    timestamps
  end

  @required_fields ~w(from to)
  @optional_fields ~w(slug)

  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> unique_constraint(:slug)
  end
end
Community
  • 1
  • 1
rubik
  • 8,814
  • 9
  • 58
  • 88
  • Yes, have the constraint in `changeset` function and in your controller when other fields are ready, generate the slug, put it in the changeset and try saving. Then match on three cases a) it worked -> continue b) changeset.error about slug -> recursively call itself to generate slug and try again c) other errors -> handle or present in GUI. – tkowal Mar 21 '16 at 09:32
  • @tkowal Ok, I got it but I have a question: how do I differentiate between errors? For example, how do I know that the insert failed because of the slug or because there were other errors? – rubik Mar 21 '16 at 11:38
  • 1
    @tkowal Nevermind. I read Ecto's source code and found that errors are placed in `changeset.error`. You said it but I didn't understand before. – rubik Mar 21 '16 at 11:43

3 Answers3

2

It's been 4 months, so I guess you figured it out. You should create different changeset depending the action you are doing and a base changeset for "read" purposes.

Explicit > Implicit

Your model could end up like that:

defmodule App.Classified do

  @rules_create %{
    :required_fields => ~w(tenant_id firstname lastname email password password_confirmation phone birthday description),
    :optional_fields => ~w(),
  }

  @rules_update %{
    :required_fields => ~w(firstname lastname email phone birthday description),
    :optional_fields => ~w()
  }

  def changeset(model, params \\ :empty) do
    model
    |> cast(params, [], [])
  end

  @doc """
  Changeset when you create a new classified
  """
  def create_changeset(model, params \\ :empty) do
    model
    |> cast(params, @rules_create.required_fields, @rules_create.optional_fields)
    |> validate_length(:description, min: 280)
    |> validate_length(:password, min: 6)
    |> validate_confirmation(:password)
    |> unique_constraint(:email)
    |> hash_password
    |> make_token
    |> make_search
  end

  @doc """
  Changeset when you update an classified
  """
  def update_changeset(model, params \\ :empty) do
    model
      |> cast(params, @rules_update.required_fields, @rules_update.optional_fields)
      |> validate_length(:description, min: 280)
      |> make_search
  end

end
Jeremie Ges
  • 2,747
  • 3
  • 22
  • 37
  • @jeremie Ges with your method, I think you might face race condition. Check this link https://elixirforum.com/t/a-case-for-validating-uniqueness/3637/16 – Mr H Apr 12 '18 at 01:36
0

Following @tkowal suggestion, I wrote the following. In the model module:

def changeset(model, params \\ :empty) do
  unless params == :empty do
    params = params |> cast_date("from") |> cast_date("to")
  end

  model
  |> cast(params, @required_fields, @optional_fields)
  |> unique_constraint(:slug)
end

defp cast_date(params, key) do
  params |> Map.update(key, nil, &Utils.to_ecto_date/1)
end

In the controller:

def create(conn, %{"event" => params}) do
  params = Map.put(params, "slug", Utils.random_string(10))
  changeset = Event.changeset(%Event{}, params)

  case Repo.insert(changeset) do
    {:ok, event} ->
      conn
      |> put_flash(:info, "Event created successfully")
      |> redirect(to: event_path(conn, :show, event.slug))
    {:error, changeset} ->
      if Keyword.has_key? changeset.errors, :slug do
        create(conn, %{"event" => params})
      else
        render conn, "new.html", changeset: changeset
      end
  end
end

All kind of feedback is welcome!

rubik
  • 8,814
  • 9
  • 58
  • 88
0
defmodule App.User.Slug do

  import Ecto.Changeset, only: [unsafe_validate_unique: 3, change: 2]

  def build_slug(changeset) do
    slug = your_fn_to_build_slug(changeset.username)  
    make_sure_unique(slug)
  end

  defp make_sure_unique(slug, attempt \\ 1) do
    slug = if attempt > 1, do: "#{slug}-#{attempt}", else: slug
    changeset = change(%User{}, slug: slug)
    changeset = unsafe_validate_unique(changeset, [:slug], App.Repo)

    if is_slug_unique(changeset) do
      slug
    else
      make_sure_unique(slug, attempt + 1)
    end
  end

  defp is_slug_unique(%Ecto.Changeset{valid?: true}), do: true
  defp is_slug_unique(_), do: false
end

model
  |> cast(params, @required_fields, @optional_fields)
  |> App.User.Slug.build_slug
  |> other_validations_you_need

Note that unsafe_validate_unique does not guarantee that it will be unique though due to racing condition. But should work in 99% cases for you.

radzserg
  • 1,258
  • 1
  • 13
  • 22