Dynamic Module Names when using Ecto functions

Just a style question; many have helped me before. I have:

  def fetch_or_insert_label(label) do
    case Repo.get_by(Label, label: label) do
      nil -> Repo.insert!(%Label{label: label})
      label -> label
    end
  end

  def fetch_or_insert_release_type(release_type) do
    case Repo.get_by(ReleaseType, release_type: release_type) do
      nil -> Repo.insert!(%ReleaseType{release_type: release_type})
      release_type -> release_type
    end
  end

I have a bunch of these - how can I do something dynamic, ie:

artist = fetch_or_insert_by_model(Artist, artist_name)
 
def fetch_or_insert_by_model(model, artist) do
   case Repo.get_by(model, artist: artist) do
      nil -> Repo.insert!(%model{artist: artist})
      artist -> artist
   end
end

This yields in the following error:

    error: expected struct name to be a compile time atom or alias, got: model
    │
 95 │       nil -> Repo.insert!(%model{artist: artist})
    │

Just wondering if there’s an easier way - I have 6-7 of these to consolidate…

Thank you

I think you’d be better off doing upserts.

Ah good call, didn’t even think of that. How could I do one method that takes care of any model passed to it?

For example:

MyRepo.insert(%Post{title: "this is unique"})

Passing in the Post?

For that you can use struct/2 to construct the data you want from a given struct module.

From the doc:

Creates and updates a struct.

The struct argument may be an atom (which defines defstruct) or a struct
itself. The second argument is any Enumerable that emits two-element tuples
(key-value pairs) during enumeration.

Keys in the Enumerable that don’t exist in the struct are automatically
discarded. Note that keys must be atoms, as only atoms are allowed when
defining a struct. If keys in the Enumerable are duplicated, the last entry
will be taken (same behaviour as Map.new/1).

This function is useful for dynamically creating and updating structs, as well
as for converting maps to structs; in the latter case, just inserting the
appropriate :struct field into the map may not be enough and struct/2
should be used instead.

## Examples

    defmodule User do
      defstruct name: "john"
    end
    
    struct(User)
    #=> %User{name: "john"}
    
    opts = [name: "meg"]
    user = struct(User, opts)
    #=> %User{name: "meg"}
    
    struct(user, unknown: "value")
    #=> %User{name: "meg"}
    
    struct(User, %{name: "meg"})
    #=> %User{name: "meg"}
    
    # String keys are ignored
    struct(User, %{"name" => "meg"})
    #=> %User{name: "john"}

Ah thank you, am trying:

  artist = fetch_or_insert_model(Artist, %{artist: artist_name})

  def fetch_or_insert_model(model, attrs) do
    model_struct = struct(model, attrs)

    {:ok, fetched_model} = Repo.insert(model_struct, on_conflict: :nothing, returning: true)
    fetched_model
  end

Seems to work great! The on_confict: :nothing doesn’t fetch the record on the second run, so will figure that out. This pattern is good though as we can reuse the method for all purposes.

I got it working with a mix of both:

   artist = fetch_or_insert_model(Artist, %{artist: artist_name})

  def fetch_or_insert_model(model, attrs) do
    model_struct = struct(model, attrs)

    case Repo.get_by(model, attrs) do
      nil -> Repo.insert!(model_struct)
      attrs -> attrs
    end
  end

If you want to insert new record or fetch existing one you can also do something like below.

Knowing that Repo.insert/1 will return {:ok, record} or {:error, changeset} you can do this.

artist = fetch_or_insert_model(Artist, %{artist: artist_name})

      def fetch_or_insert_model(model, attrs) do
        model_struct = struct(model, attrs)

        case Repo.insert(model_struct) do
          {:ok, entity} -> entity
          {:error, changeset} -> Repo.get_by!(model, changeset.data)
        end
      end

This is inspired from Phoenix doc : Cross-context data.

I think reading all the page will be helpful but the more relevant part is this.

def ensure_author_exists(%Accounts.User{} = user) do
  %Author{user_id: user.id}
  |> Ecto.Changeset.change()
  |> Ecto.Changeset.unique_constraint(:user_id)
  |> Repo.insert()
  |> handle_existing_author()
end
defp handle_existing_author({:ok, author}), do: author
defp handle_existing_author({:error, changeset}) do
  Repo.get_by!(Author, user_id: changeset.data.user_id)
end
1 Like

Hm, why did you give up on the upsert? You can give it the right conflict target and you should be fine.

I would love the upset to work, but no matter what I did with on_conflict, I would not get the already-present row from the database (on the second run).