Ecto helpers to find_or_create(_by) and upsert(_by)

Hey everyone, looking for thoughts, feedback, comments on this library EctoConditionals I’m working on that adds helper functions to conditionally find or create and upsert when given multiple fields as uniquely identifying selectors. An example is when upserting a user by both an organization_id and an external_user_id. This would be necessary when the external_user_id is not unique across all organizations, but unique within any one organization.

Here’s a snippet from the readme

%User{name: "Slughorn"} |> find_or_create_by(:name)
#=> {:ok, %User{id: 3, name: "Slughorn"}}

%User{first_name: "Harry", last_name: "Potter"} |> upsert_by(:last_name)
#=> {:ok, %User{id: 4, first_name: "Harry", last_name: "Potter"}}

Also a question for the crowd: I’ve kind of tested this by pointing a toy phoenix app with ecto and postgresql db to the local package, but was hoping if there are any other ways to test Ecto helper packages like these from the package itself without actually requiring a db. Thanks!

1 Like

maybe @michalmuskala will have some insight here.

You could try using a mocked repo adapter similar to what ecto uses itself for testing without database - https://github.com/elixir-ecto/ecto/blob/master/test/support/test_repo.exs

2 Likes

Doesn’t the code have a race condition if the user is inserted by some other process between the initial find and the insert?

3 Likes

Hmm, that’s a good point… :thinking:

This will more likely than not cause potential race conditions for the find_or_create(_by) since it’d pass a struct to an insert which could then lead to duplicates. Any ideas on how to handle this?

Regarding the upsert(_by), that took inspiration from the example given for Ecto.Repo#insert_or_update/2 by passing a changeset to the insert_or_update. Looking at it again, I’m curious whether the insert_or_update knows how to gracefully handle race conditions since it similarly breaks apart the find and upsert into discrete steps. @michalmuskala, any insight here?

result =
  case MyRepo.get(Post, id) do
    nil  -> %Post{id: id} # Post not found, we build one
    post -> post          # Post exists, let's use it
  end
  |> Post.changeset(changes)
  |> MyRepo.insert_or_update

case result do
  {:ok, struct}       -> # Inserted or updated with success
  {:error, changeset} -> # Something went wrong
end

source: Ecto docs

Thanks @pragdave for bringing this up!

1 Like

You can use the conflict resolution stuff, at least when using Postgres.

1 Like