Create nested associations with unique constraints

Slight twist on the usual nested associations question here:

I have two models (in this simple example, in reality, it’s dozens and dozens). Part and PartManufacturer.

The schema for Part looks like this:

schema "parts" do
  field :model, :string
  belongs_to :manufacturer, PartManufacturer
end

The schema for PartManufacturer looks like this:

schema "part_manufacturers" do
  field :name, :string
end

The changes for inserting a new Part looks like this:

def changeset(part, attrs) do
  part
  |> cast(attrs, [:model])
  |> cast_assoc(:manufacturer)
end

Which works exactly as expected when using a Phoenix nested form (or other any other input method).

I would like to keep PartManufacturer tidy, with a unique constraint on it’s name field, which I’ve added in it’s changeset and on the DB table.

It works as expected, in that it prevents duplicate rows being inserted, throwing a changes error.

However, I’d like to use that pre-existing record within the Part as it’s association, rather than throwing an error. Would I be right in saying that I need to approach it similarly to the ensure_author_exists function from this part of the Phoenix guide: https://hexdocs.pm/phoenix/contexts.html#cross-context-data

In my case, that would involve pulling the manufacturer map from the params, passing it through a similar function, and then mapping it back into the Part attrs before using assoc_constraint in the changes as opposed to cast_assoc. Is that correct?

I’m scratching my head on this one, as I can’t get the right behaviour, and a bit nervous that I’ve bitten off more than I can chew. The end goal is a Bike struct (and form) that has associations for each of it’s varying parts Brake, Fork etc, which in turn has associations to BrakeType, BrakeManufacturer etc. I’d like to be able to prompt users with auto-suggestions based on existing values, but also add new ones when using the form with nested associations. It’s certainly going to be fiddly!

Cheers,

Jamie.

2 Likes

Thought I’d update this with my current solution. It works as I want, but still feel that it could be better. Some sections feel a bit mechanical and wasteful.

I’ve put the code into a Snippet, just because there’s a fair amount of it, and perhaps can serve as a better reference point (for both myself and others?)

Any advice, comments, criticisms on my solution?

1 Like

Just spotted a different way to accomplish this using upserts in the Ecto 2.0 PDF.

What are the benefits to this:

def ensure_exists(name) do
  Repo.insert!(
    %Manufacturer{name: name},
      on_conflict: [set: [name: name]],
      conflict_target: :name
     )
end

over my original method:

def create_manufacturer(attrs \\ %{}) do
  %Manufacturer{}
  |> changeset(attrs)
  |> Repo.insert
end

def ensure_exists(manufacturer) do
  manufacturer
  |> create_manufacturer
  |> handle_existing
end

defp handle_existing({:ok, manufacturer}), do: manufacturer
defp handle_existing({:error, changeset}) do
  # Again, having to grab the value from the changeset in this way feels mechanical
  get_by_type(changeset.changes.name)
end

I’ve compared the two, and both seem fairly equivalent in terms of speed, although mine has “QUERY ERROR” in the debug logs, and the upsert has “QUERY OK”. Any other differences that I’m missing?

1 Like

Another thought…

In this case, it’s very rare that I’ll be interacting with one schema at a time.

Most often, I’ll be inserting a Bike which will either get or inserted the specified Fork which will in turn get or insert the ForkManufacturer, the ForkType and the ForkMaterial. This process happens for all components and facets of a bike, and there are at least 30 of these, all with similar associations themselves. They have on average 2-3 associations, and each of those take up multiple database transactions (because of the ensure_exist function).

This means that each Bike insert actually requires anywhere from 180 to ~450 DB transactions, which is nuts!

Have I got this modelling all wrong? Or should I be used something like Ecto.Multi here, even though the upsert or ensure_exists function means that the transactions aren’t conditional?

1 Like