Idiomatic method of handling associations in context's and changesets

I have a Player model and a Match model. Matches belong_to a player and a player has_many matches.

A player is created manually, but matches are pulled asynchronously from an external API. To build the relationship, my first impulse was to do something like:

player = get_player_from_somewhere()
for match <- match_data do
  attrs = %{
    length: match["length"],
    score: match["score"],
    player: player
  }
  Matches.create_match(attrs)
end

And inside Match.ex, I had

def changeset(match, attrs) do
  match
  |> cast(attr, [...])
  |> cast_assoc(:player)
end

Now this fails because it actually expects player to be a map of player attributes, not an already constructed object (as per the docs).

I could alter my code to be:

player = get_player_from_somewhere()
for match <- match_data do
  attrs = %{
    length: match["length"]
    score: match["score"]
    player: %{
      id: player.id
    }
  }
  Matches.create_match(attrs)
end

But that seems like a bit of a hack, might as well just set player_id.

I come from a Rails background, where I felt like I would be calling something more like Matches.create_match_for_player(attr, player) if I were dealing with nested resource.

I settled on altering my Matches.ex context with

  def create_match_for_player(attrs \\ %{}, player) do
    %Match{}
    |> Match.changeset(attrs, player)
    |> Repo.insert()
  end

and Match.ex to

def changeset(match, attrs, player) do
  match
  |> cast(attr, [...])
  |> put_assoc(player)
end

Is this the correct way of laying out this code? I could also go player |> build_assoc(:match, attrs). I’m unsure which is better.

Alternatively I read this method of using foreign_key_constraint and basically ignoring the “fancy” parts of relationships.

Part of me think’s this is throwing out a lot of cool stuff, but Ecto also doesn’t do things like preloading automatically, so maybe working with just id’s until you want to operate on an object is more natural.

I do like the idea of simplifying (?) the changeset code by removing any association casts and relying on the DB to alert errors. It seems almost more explicit.

I see no reason why your cast_assoc would not accept player_id unless you never allowed it.

In my code I usually do something like this in the Ecto schema files (adapted to your example):

@required_fields [:length, :score, :player_id] # notice it's "player_id" here, not "player".
@optional_fields []

def changeset(%Match{} = match, params \\ %{}, _opts \\ %{}) do
  match
  |> cast(params, @required_fields ++ @optional_fields)
  |> validate_required(@required_fields)
  |> cast_assoc(:player)
end

Definitely do NOT use put_assoc here for your case (explanation in next paragraph). And don’t get misled by the common examples; you can have as many changeset-creating or altering functions as you like. You don’t have to stick to only one. That’s just what’s initially generated for you and it’s not a hard rule.

The normal changeset function that gets generated can be most accurately described as “create a changeset from incoming external data” (that’s what cast_* means in Ecto and is used for constructing changesets coming from HTTP form data most of the time). If you want to explicitly set any association without it coming from external data – if it is coming from another of your internal modules/functions for example – then put_assoc is much more apt (since it accepts it without the extra validation work that cast_assoc does).

Here’s one example that will hopefully clear your doubts when working with Ecto. In addition to the changeset function you already have, consider the theoretical scenario when you want to reassign a game to another player:

def reassign_game_to_another_player_changeset(%Match{} = match, %Player{} = player) do
  match
  |> Changeset.change()
  |> Changeset.put_assoc(:player, player)
end

…and then use the result of this function and feed it to Repo.update somewhere else.

In my experience it’s a bad idea to rely on DB errors. They are hard to parse and present to an user well. Also they make a full request-response cycle to the DB for something that you definitely can encode as expectations in your Elixir code and save the network hop. The slight downside is that you have to duplicate non-null / unique / other constraints in your migrations AND your Ecto schema files but that’s true for all languages and ORMs / data mappers anyway (not specific to Ecto). However, it gives you much better error messages and allows you to aim precisely for whatever errors might come your way. And you don’t go to the DB just to fetch a validation error.

Does that help?